node-red-contrib-alice 2.3.6 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +48 -0
- package/nodes/alice-device.html +3 -0
- package/nodes/alice.js +192 -47
- package/package.json +7 -3
- package/src/alice-device.html +3 -0
- package/src/alice.ts +235 -47
- package/.claude/settings.local.json +0 -11
- package/CLAUDE.md +0 -54
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v3.0.0 — WebSocket вместо MQTT (миграция с Yandex IoT Core)
|
|
4
|
+
|
|
5
|
+
### ⚠️ Зачем обновляться
|
|
6
|
+
|
|
7
|
+
Яндекс закрывает сервис **Yandex IoT Core**, через который версии 2.x получали команды Алисы:
|
|
8
|
+
|
|
9
|
+
- **1 ноября 2026** — брокер переходит в read-only: голосовые команды и управление из приложения на версиях 2.x **перестанут работать** (состояния и датчики продолжат отправляться — они идут по HTTPS);
|
|
10
|
+
- **1 декабря 2026** — сервис отключается полностью.
|
|
11
|
+
|
|
12
|
+
Версия 3.0.0 от IoT Core не зависит. **Обновитесь до 1 ноября 2026.**
|
|
13
|
+
|
|
14
|
+
### Что изменилось
|
|
15
|
+
|
|
16
|
+
- **Транспорт команд**: MQTT (`mqtt.cloud.yandex.net:8883`, пароли устройств) → WebSocket (`wss://ws.nodered-home.ru/v1/ws`, авторизация тем же OAuth-токеном Яндекса, что и остальные запросы).
|
|
17
|
+
- **Зависимости**: `mqtt` заменён на `ws` — пакет стал легче.
|
|
18
|
+
- **Сеть**: исходящее подключение на стандартный порт **443** вместо 8883 — проще за файрволами и корпоративными прокси.
|
|
19
|
+
- **Надёжность**: keep-alive ping/pong, автоматический реконнект с экспоненциальным backoff (1–60 с) и джиттером.
|
|
20
|
+
- **Мультиинстансы**: до 5 одновременных подключений Node-RED под одним аккаунтом — в MQTT-версии инстансы с одним clientId вытесняли друг друга.
|
|
21
|
+
|
|
22
|
+
### Обновление
|
|
23
|
+
|
|
24
|
+
Прямо из интерфейса Node-RED: меню → **Управление палитрой** (Manage palette) → вкладка **Ноды** → найдите `node-red-contrib-alice` → **Обновить**, затем перезапустите Node-RED.
|
|
25
|
+
|
|
26
|
+
Альтернатива через npm:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
cd ~/.node-red
|
|
30
|
+
npm install node-red-contrib-alice@latest
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Переавторизация и перенастройка не требуются**: флоу, настройки нод и существующая авторизация Яндекса продолжают работать. Ноды устройств и умений не менялись — обновляется только служебная нода подключения.
|
|
34
|
+
|
|
35
|
+
### Совместимость
|
|
36
|
+
|
|
37
|
+
- Node.js ≥ 14, Node-RED ≥ 3.0
|
|
38
|
+
- Формат сообщений на входах/выходах нод не изменился
|
|
39
|
+
- Версии 2.x продолжат работать до 1 ноября 2026, дальше — только датчики/состояния, без голосовых команд
|
package/README.md
CHANGED
|
@@ -16,6 +16,30 @@ Integration of Node-RED with Yandex Smart Home. Connect any device from Node-RED
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
+
## ⚠️ Обновитесь до версии 3.x до 1 ноября 2026
|
|
20
|
+
|
|
21
|
+
Яндекс закрывает сервис **Yandex IoT Core**, через который версии 2.x получали команды Алисы (MQTT):
|
|
22
|
+
|
|
23
|
+
- **1 ноября 2026** — брокер переходит в режим read-only: голосовые команды («Алиса, включи свет») и управление из приложения **перестанут работать** на версиях 2.x. Отправка состояний и датчиков продолжит работать (она идёт по HTTPS).
|
|
24
|
+
- **1 декабря 2026** — сервис отключается полностью.
|
|
25
|
+
|
|
26
|
+
Начиная с **версии 3.0.0** плагин не зависит от IoT Core: команды доставляются по WebSocket (`wss://ws.nodered-home.ru`, исходящее подключение на стандартный порт 443 вместо 8883 — проще за файрволами и прокси).
|
|
27
|
+
|
|
28
|
+
**Как обновиться** — прямо из интерфейса Node-RED: меню → **Управление палитрой** (Manage palette) → вкладка **Ноды** → найдите `node-red-contrib-alice` → кнопка **Обновить**, затем перезапустите Node-RED.
|
|
29
|
+
|
|
30
|
+
Альтернатива через npm:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
cd ~/.node-red
|
|
34
|
+
npm install node-red-contrib-alice@latest
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Флоу, настройки и авторизация сохраняются — переавторизация и перенастройка устройств **не требуются**.
|
|
38
|
+
|
|
39
|
+
Бонус: теперь можно подключать несколько инстансов Node-RED под одним аккаунтом (до 5 одновременных подключений) — в MQTT-версии они конфликтовали.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
19
43
|
## Быстрый старт
|
|
20
44
|
|
|
21
45
|
1. Установите Node-RED ([инструкция](https://nodered.org/docs/getting-started/))
|
|
@@ -57,6 +81,30 @@ Integration of Node-RED with Yandex Smart Home. Connect any device from Node-RED
|
|
|
57
81
|
|
|
58
82
|
---
|
|
59
83
|
|
|
84
|
+
## ⚠️ Update to version 3.x before November 1, 2026
|
|
85
|
+
|
|
86
|
+
Yandex is shutting down **Yandex IoT Core**, the MQTT service that versions 2.x rely on for receiving Alice commands:
|
|
87
|
+
|
|
88
|
+
- **November 1, 2026** — the broker becomes read-only: voice commands and app control **will stop working** on 2.x. Sending states and sensor data will keep working (it goes over HTTPS).
|
|
89
|
+
- **December 1, 2026** — the service shuts down completely.
|
|
90
|
+
|
|
91
|
+
Starting with **version 3.0.0** the plugin no longer depends on IoT Core: commands are delivered over WebSocket (`wss://ws.nodered-home.ru`, outgoing connection on standard port 443 instead of 8883 — friendlier to firewalls and proxies).
|
|
92
|
+
|
|
93
|
+
**How to update** — right from the Node-RED editor: menu → **Manage palette** → **Nodes** tab → find `node-red-contrib-alice` → click **Update**, then restart Node-RED.
|
|
94
|
+
|
|
95
|
+
Alternatively via npm:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
cd ~/.node-red
|
|
99
|
+
npm install node-red-contrib-alice@latest
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Flows, settings and authorization are preserved — **no re-authentication or device reconfiguration required**.
|
|
103
|
+
|
|
104
|
+
Bonus: you can now connect several Node-RED instances under one account (up to 5 simultaneous connections) — MQTT versions conflicted with each other.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
60
108
|
## Quick Start
|
|
61
109
|
|
|
62
110
|
1. Install Node-RED ([guide](https://nodered.org/docs/getting-started/))
|
package/nodes/alice-device.html
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
{
|
|
18
18
|
options: [
|
|
19
19
|
{ value: "devices.types.light", label: "Light" },
|
|
20
|
+
{ value: "devices.types.light.lamp", label: "Light Lamp" },
|
|
20
21
|
{ value: "devices.types.light.ceiling", label: "Light Сeiling" },
|
|
21
22
|
{ value: "devices.types.light.strip", label: "Light Strip" },
|
|
22
23
|
{ value: "devices.types.socket", label: "Socket" },
|
|
23
24
|
{ value: "devices.types.switch", label: "Switch" },
|
|
25
|
+
{ value: "devices.types.switch.relay", label: "Relay" },
|
|
24
26
|
{ value: "devices.types.thermostat", label: "Thermostat" },
|
|
25
27
|
{ value: "devices.types.thermostat.ac", label: "Air conditioning" },
|
|
26
28
|
{ value: "devices.types.media_device", label: "Multimedia" },
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
{ value: "devices.types.openable", label: "Door, gate, window, shutters" },
|
|
36
38
|
{ value: "devices.types.openable.curtain", label: "Curtains, blinds" },
|
|
37
39
|
{ value: "devices.types.openable.valve", label: "Valve (ball valve)" },
|
|
40
|
+
{ value: "devices.types.openable.door_lock", label: "Door lock" },
|
|
38
41
|
{ value: "devices.types.humidifier", label: "Humidifier" },
|
|
39
42
|
{ value: "devices.types.purifier", label: "Air purifier" },
|
|
40
43
|
{ value: "devices.types.ventilation", label: "Ventilation" },
|
package/nodes/alice.js
CHANGED
|
@@ -3,14 +3,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
const axios_1 = __importDefault(require("axios"));
|
|
6
|
-
const
|
|
6
|
+
const ws_1 = __importDefault(require("ws"));
|
|
7
|
+
const WS_URL = process.env.ALICE_WS_URL || "wss://ws.nodered-home.ru/v1/ws";
|
|
8
|
+
const RECONNECT_MIN_MS = 1000;
|
|
9
|
+
const RECONNECT_MAX_MS = 60000;
|
|
10
|
+
const RECONNECT_JITTER_MS = 10000;
|
|
11
|
+
const WATCHDOG_TIMEOUT_MS = 90000;
|
|
12
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
13
|
+
const CLOSE_FAILSAFE_MS = 1500;
|
|
7
14
|
module.exports = (RED) => {
|
|
8
15
|
function AliceService(config) {
|
|
9
16
|
RED.nodes.createNode(this, config);
|
|
10
17
|
this.debug("Starting Alice service... ID: " + this.id);
|
|
11
18
|
const email = this.credentials.email;
|
|
12
|
-
const login = this.credentials.id;
|
|
13
|
-
const password = this.credentials.password;
|
|
14
19
|
const token = this.credentials.token;
|
|
15
20
|
const suburl = Buffer.from(email).toString('base64');
|
|
16
21
|
RED.httpAdmin.get("/noderedhome/" + suburl + "/clearalldevice", (_req, res) => {
|
|
@@ -55,49 +60,146 @@ module.exports = (RED) => {
|
|
|
55
60
|
this.error("Authentication is required!!!");
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
63
|
+
let accessToken;
|
|
64
|
+
try {
|
|
65
|
+
accessToken = JSON.parse(token).access_token;
|
|
66
|
+
if (!accessToken) {
|
|
67
|
+
throw new Error("access_token is empty");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (_err) {
|
|
71
|
+
this.error("Authentication token is corrupted, please re-authenticate in node settings!!!");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
let ws = null;
|
|
75
|
+
let closing = false;
|
|
76
|
+
let reconnectDelay = RECONNECT_MIN_MS;
|
|
77
|
+
let reconnectTimer = null;
|
|
78
|
+
let watchdogTimer = null;
|
|
79
|
+
const stopWatchdog = () => {
|
|
80
|
+
if (watchdogTimer) {
|
|
81
|
+
clearTimeout(watchdogTimer);
|
|
82
|
+
watchdogTimer = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const resetWatchdog = () => {
|
|
86
|
+
stopWatchdog();
|
|
87
|
+
watchdogTimer = setTimeout(() => {
|
|
88
|
+
watchdogTimer = null;
|
|
89
|
+
this.debug("WS watchdog: no activity for " + WATCHDOG_TIMEOUT_MS + "ms, terminating connection");
|
|
90
|
+
if (ws) {
|
|
91
|
+
ws.terminate();
|
|
92
|
+
}
|
|
93
|
+
}, WATCHDOG_TIMEOUT_MS);
|
|
94
|
+
};
|
|
95
|
+
const scheduleReconnect = () => {
|
|
96
|
+
if (closing || reconnectTimer) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const delay = reconnectDelay + Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
|
100
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
101
|
+
this.debug("WS reconnect scheduled in " + delay + "ms");
|
|
102
|
+
reconnectTimer = setTimeout(() => {
|
|
103
|
+
reconnectTimer = null;
|
|
104
|
+
connect();
|
|
105
|
+
}, delay);
|
|
106
|
+
};
|
|
107
|
+
const connect = () => {
|
|
108
|
+
if (closing) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.debug("Connecting to WS gateway: " + WS_URL);
|
|
112
|
+
const socket = new ws_1.default(WS_URL, {
|
|
113
|
+
headers: { Authorization: "Bearer " + accessToken },
|
|
114
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS
|
|
115
|
+
});
|
|
116
|
+
ws = socket;
|
|
117
|
+
socket.on("open", () => {
|
|
118
|
+
this.debug("WS connection opened, waiting for hello");
|
|
119
|
+
resetWatchdog();
|
|
120
|
+
});
|
|
121
|
+
socket.on("ping", () => {
|
|
122
|
+
resetWatchdog();
|
|
123
|
+
});
|
|
124
|
+
socket.on("message", (raw) => {
|
|
125
|
+
resetWatchdog();
|
|
126
|
+
let frame;
|
|
127
|
+
try {
|
|
128
|
+
frame = JSON.parse(raw.toString());
|
|
129
|
+
}
|
|
130
|
+
catch (_err) {
|
|
131
|
+
this.warn("WS: invalid JSON frame ignored");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (!frame || typeof frame !== "object") {
|
|
135
|
+
this.warn("WS: unexpected frame ignored");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
switch (frame.type) {
|
|
139
|
+
case "hello":
|
|
140
|
+
this.debug("WS gateway connected (hello received)");
|
|
141
|
+
reconnectDelay = RECONNECT_MIN_MS;
|
|
142
|
+
this.emit('online');
|
|
143
|
+
break;
|
|
144
|
+
case "command":
|
|
145
|
+
this.trace("Incoming command devId:" + frame.devId + " timestamp:" + new Date().getTime());
|
|
146
|
+
if (!Array.isArray(frame.payload)) {
|
|
147
|
+
this.warn("WS: command frame without payload array ignored. devId:" + frame.devId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (frame.devId) {
|
|
151
|
+
try {
|
|
152
|
+
this.emit(String(frame.devId), frame.payload);
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
this.warn("WS: command listener error: " + (err instanceof Error ? err.message : String(err)));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
case "message":
|
|
160
|
+
if (typeof frame.text === "string") {
|
|
161
|
+
this.warn(frame.text);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.trace("WS: message frame without text ignored");
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
this.trace("WS: unknown frame type ignored: " + frame.type);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
socket.on("unexpected-response", (req, res) => {
|
|
172
|
+
stopWatchdog();
|
|
173
|
+
if (res.statusCode === 401) {
|
|
174
|
+
this.error("WS gateway rejected token (401): токен недействителен, переавторизуйтесь в настройках ноды");
|
|
73
175
|
}
|
|
74
176
|
else {
|
|
75
|
-
this.
|
|
177
|
+
this.debug("WS unexpected server response: HTTP " + res.statusCode);
|
|
76
178
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
this.debug("Yandex IOT client subscribed to the command");
|
|
179
|
+
req.destroy();
|
|
180
|
+
if (ws === socket) {
|
|
181
|
+
ws = null;
|
|
182
|
+
}
|
|
183
|
+
this.emit('offline');
|
|
184
|
+
scheduleReconnect();
|
|
84
185
|
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
186
|
+
socket.on("error", (err) => {
|
|
187
|
+
this.debug("WS error: " + err.message);
|
|
188
|
+
});
|
|
189
|
+
socket.on("close", (code, reason) => {
|
|
190
|
+
stopWatchdog();
|
|
191
|
+
if (code === 4429) {
|
|
192
|
+
this.warn("WS gateway closed connection (4429): превышен лимит подключений — не более 5 на пользователя");
|
|
193
|
+
}
|
|
194
|
+
this.debug("WS connection closed. code:" + code + " reason:" + reason.toString());
|
|
195
|
+
if (ws === socket) {
|
|
196
|
+
ws = null;
|
|
197
|
+
}
|
|
198
|
+
this.emit('offline');
|
|
199
|
+
scheduleReconnect();
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
connect();
|
|
101
203
|
this.on('offline', () => {
|
|
102
204
|
this.isOnline = false;
|
|
103
205
|
});
|
|
@@ -105,17 +207,60 @@ module.exports = (RED) => {
|
|
|
105
207
|
this.isOnline = true;
|
|
106
208
|
});
|
|
107
209
|
this.on('close', (done) => {
|
|
210
|
+
closing = true;
|
|
211
|
+
if (reconnectTimer) {
|
|
212
|
+
clearTimeout(reconnectTimer);
|
|
213
|
+
reconnectTimer = null;
|
|
214
|
+
}
|
|
215
|
+
stopWatchdog();
|
|
108
216
|
this.emit('offline');
|
|
217
|
+
const socket = ws;
|
|
218
|
+
ws = null;
|
|
219
|
+
if (!socket || socket.readyState === ws_1.default.CLOSED) {
|
|
220
|
+
done();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
let doneCalled = false;
|
|
224
|
+
const finish = () => {
|
|
225
|
+
if (!doneCalled) {
|
|
226
|
+
doneCalled = true;
|
|
227
|
+
done();
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
socket.once('close', finish);
|
|
231
|
+
try {
|
|
232
|
+
socket.close(1000);
|
|
233
|
+
}
|
|
234
|
+
catch (_err) {
|
|
235
|
+
socket.terminate();
|
|
236
|
+
}
|
|
109
237
|
setTimeout(() => {
|
|
110
|
-
|
|
111
|
-
|
|
238
|
+
if (socket.readyState !== ws_1.default.CLOSED) {
|
|
239
|
+
socket.terminate();
|
|
240
|
+
}
|
|
241
|
+
finish();
|
|
242
|
+
}, CLOSE_FAILSAFE_MS).unref();
|
|
112
243
|
});
|
|
113
|
-
this.send2gate = (path, data,
|
|
244
|
+
this.send2gate = (path, data, _retain) => {
|
|
114
245
|
this.trace("Outgoing: " + path);
|
|
115
|
-
|
|
246
|
+
const arrPath = path.split('/');
|
|
247
|
+
const devId = arrPath[arrPath.length - 1];
|
|
248
|
+
if (!ws || ws.readyState !== ws_1.default.OPEN) {
|
|
249
|
+
this.debug("WS not connected, command_result dropped. devId:" + devId);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let payload;
|
|
253
|
+
try {
|
|
254
|
+
payload = JSON.parse(data);
|
|
255
|
+
}
|
|
256
|
+
catch (_err) {
|
|
257
|
+
this.warn("send2gate: invalid JSON data ignored. devId:" + devId);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
ws.send(JSON.stringify({ v: 1, type: "command_result", devId: devId, payload: payload }));
|
|
116
261
|
};
|
|
117
262
|
this.getToken = () => {
|
|
118
|
-
return
|
|
263
|
+
return accessToken;
|
|
119
264
|
};
|
|
120
265
|
}
|
|
121
266
|
RED.nodes.registerType("alice-service", AliceService, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-alice",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"start": "npm run build && node-red",
|
|
@@ -44,16 +44,20 @@
|
|
|
44
44
|
"homepage": "https://github.com/efa2000/node-red-contrib-alice#readme",
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"axios": "^1.4.0",
|
|
47
|
-
"
|
|
47
|
+
"ws": "^8.18.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@types/node": "^20.11.16",
|
|
51
51
|
"@types/node-red": "^1.3.4",
|
|
52
|
+
"@types/ws": "^8.5.12",
|
|
52
53
|
"nodemon": "^3.0.3",
|
|
53
54
|
"typescript": "^5.3.3"
|
|
54
55
|
},
|
|
55
56
|
"nodemonConfig": {
|
|
56
|
-
"ignoreRoot": [
|
|
57
|
+
"ignoreRoot": [
|
|
58
|
+
".git",
|
|
59
|
+
"test"
|
|
60
|
+
],
|
|
57
61
|
"restartable": "rs",
|
|
58
62
|
"ignore": [
|
|
59
63
|
"node_modules/**/node_modules",
|
package/src/alice-device.html
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
{
|
|
18
18
|
options: [
|
|
19
19
|
{ value: "devices.types.light", label: "Light" },
|
|
20
|
+
{ value: "devices.types.light.lamp", label: "Light Lamp" },
|
|
20
21
|
{ value: "devices.types.light.ceiling", label: "Light Сeiling" },
|
|
21
22
|
{ value: "devices.types.light.strip", label: "Light Strip" },
|
|
22
23
|
{ value: "devices.types.socket", label: "Socket" },
|
|
23
24
|
{ value: "devices.types.switch", label: "Switch" },
|
|
25
|
+
{ value: "devices.types.switch.relay", label: "Relay" },
|
|
24
26
|
{ value: "devices.types.thermostat", label: "Thermostat" },
|
|
25
27
|
{ value: "devices.types.thermostat.ac", label: "Air conditioning" },
|
|
26
28
|
{ value: "devices.types.media_device", label: "Multimedia" },
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
{ value: "devices.types.openable", label: "Door, gate, window, shutters" },
|
|
36
38
|
{ value: "devices.types.openable.curtain", label: "Curtains, blinds" },
|
|
37
39
|
{ value: "devices.types.openable.valve", label: "Valve (ball valve)" },
|
|
40
|
+
{ value: "devices.types.openable.door_lock", label: "Door lock" },
|
|
38
41
|
{ value: "devices.types.humidifier", label: "Humidifier" },
|
|
39
42
|
{ value: "devices.types.purifier", label: "Air purifier" },
|
|
40
43
|
{ value: "devices.types.ventilation", label: "Ventilation" },
|
package/src/alice.ts
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
import { NodeAPI } from "node-red";
|
|
2
2
|
import axios from "axios";
|
|
3
|
-
import
|
|
3
|
+
import WebSocket from "ws";
|
|
4
4
|
import { AliceServiceConfig, AliceServiceNode } from "./types.js";
|
|
5
5
|
|
|
6
|
+
// Адрес WebSocket-шлюза. Для отладки и тестов можно переопределить
|
|
7
|
+
// через переменную окружения ALICE_WS_URL.
|
|
8
|
+
const WS_URL = process.env.ALICE_WS_URL || "wss://ws.nodered-home.ru/v1/ws";
|
|
9
|
+
|
|
10
|
+
// Реконнект: экспоненциальный backoff от 1 до 60 секунд (×2) + джиттер 0–10 секунд
|
|
11
|
+
const RECONNECT_MIN_MS = 1000;
|
|
12
|
+
const RECONNECT_MAX_MS = 60000;
|
|
13
|
+
const RECONNECT_JITTER_MS = 10000;
|
|
14
|
+
|
|
15
|
+
// Watchdog: сервер шлёт ping каждые 30 секунд; если ~90 секунд нет ни ping,
|
|
16
|
+
// ни сообщений — считаем соединение мёртвым и рвём его сами
|
|
17
|
+
const WATCHDOG_TIMEOUT_MS = 90000;
|
|
18
|
+
|
|
19
|
+
// Таймаут WS-хендшейка: если сервер принял TCP, но не ответил на upgrade,
|
|
20
|
+
// библиотека ws сама эмитит error+close — дальше штатный реконнект
|
|
21
|
+
const HANDSHAKE_TIMEOUT_MS = 15000;
|
|
22
|
+
|
|
23
|
+
// Failsafe при закрытии ноды: если сервер не завершил close-хендшейк
|
|
24
|
+
// за это время — рвём сокет и отпускаем done()
|
|
25
|
+
const CLOSE_FAILSAFE_MS = 1500;
|
|
26
|
+
|
|
27
|
+
// Кадр протокола WS-шлюза (JSON text frame)
|
|
28
|
+
interface GateFrame {
|
|
29
|
+
v?: number;
|
|
30
|
+
type?: string;
|
|
31
|
+
reqId?: string;
|
|
32
|
+
devId?: string;
|
|
33
|
+
payload?: unknown;
|
|
34
|
+
text?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export = (RED: NodeAPI): void => {
|
|
7
38
|
function AliceService(this: AliceServiceNode, config: AliceServiceConfig): void {
|
|
8
39
|
RED.nodes.createNode(this, config);
|
|
9
40
|
this.debug("Starting Alice service... ID: " + this.id);
|
|
10
41
|
|
|
11
42
|
const email = this.credentials.email;
|
|
12
|
-
const login = this.credentials.id;
|
|
13
|
-
const password = this.credentials.password;
|
|
14
43
|
const token = this.credentials.token;
|
|
15
44
|
|
|
16
45
|
//вызов для удаления всех устройств
|
|
@@ -61,54 +90,166 @@ export = (RED: NodeAPI): void => {
|
|
|
61
90
|
return;
|
|
62
91
|
}
|
|
63
92
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
93
|
+
// Токен хранится как JSON-строка OAuth-ответа Яндекса
|
|
94
|
+
let accessToken: string;
|
|
95
|
+
try {
|
|
96
|
+
accessToken = JSON.parse(token).access_token;
|
|
97
|
+
if (!accessToken) {
|
|
98
|
+
throw new Error("access_token is empty");
|
|
99
|
+
}
|
|
100
|
+
} catch (_err) {
|
|
101
|
+
this.error("Authentication token is corrupted, please re-authenticate in node settings!!!");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
72
104
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
105
|
+
// ---------- WebSocket-транспорт (замена MQTT / Yandex IoT Core) ----------
|
|
106
|
+
|
|
107
|
+
let ws: WebSocket | null = null; // текущее соединение
|
|
108
|
+
let closing = false; // нода закрывается — реконнект запрещён
|
|
109
|
+
let reconnectDelay = RECONNECT_MIN_MS; // текущая база backoff
|
|
110
|
+
let reconnectTimer: NodeJS.Timeout | null = null;
|
|
111
|
+
let watchdogTimer: NodeJS.Timeout | null = null;
|
|
112
|
+
|
|
113
|
+
const stopWatchdog = () => {
|
|
114
|
+
if (watchdogTimer) {
|
|
115
|
+
clearTimeout(watchdogTimer);
|
|
116
|
+
watchdogTimer = null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Любая активность от сервера (ping или сообщение) сбрасывает watchdog
|
|
121
|
+
const resetWatchdog = () => {
|
|
122
|
+
stopWatchdog();
|
|
123
|
+
watchdogTimer = setTimeout(() => {
|
|
124
|
+
watchdogTimer = null;
|
|
125
|
+
this.debug("WS watchdog: no activity for " + WATCHDOG_TIMEOUT_MS + "ms, terminating connection");
|
|
126
|
+
if (ws) {
|
|
127
|
+
ws.terminate(); // событие close запустит обычный реконнект
|
|
82
128
|
}
|
|
129
|
+
}, WATCHDOG_TIMEOUT_MS);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const scheduleReconnect = () => {
|
|
133
|
+
if (closing || reconnectTimer) {
|
|
134
|
+
return;
|
|
83
135
|
}
|
|
84
|
-
|
|
136
|
+
const delay = reconnectDelay + Math.floor(Math.random() * RECONNECT_JITTER_MS);
|
|
137
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
|
138
|
+
this.debug("WS reconnect scheduled in " + delay + "ms");
|
|
139
|
+
reconnectTimer = setTimeout(() => {
|
|
140
|
+
reconnectTimer = null;
|
|
141
|
+
connect();
|
|
142
|
+
}, delay);
|
|
143
|
+
};
|
|
85
144
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
145
|
+
const connect = () => {
|
|
146
|
+
if (closing) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.debug("Connecting to WS gateway: " + WS_URL);
|
|
150
|
+
const socket = new WebSocket(WS_URL, {
|
|
151
|
+
headers: { Authorization: "Bearer " + accessToken },
|
|
152
|
+
handshakeTimeout: HANDSHAKE_TIMEOUT_MS
|
|
91
153
|
});
|
|
92
|
-
|
|
154
|
+
ws = socket;
|
|
93
155
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
socket.on("open", () => {
|
|
157
|
+
// online эмитим не здесь, а по кадру hello: сервер может закрыть
|
|
158
|
+
// уже открытое соединение кодом 4429 (превышен лимит подключений)
|
|
159
|
+
this.debug("WS connection opened, waiting for hello");
|
|
160
|
+
resetWatchdog();
|
|
161
|
+
});
|
|
98
162
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
163
|
+
// Стандартный WS ping от сервера (pong библиотека ws шлёт сама)
|
|
164
|
+
socket.on("ping", () => {
|
|
165
|
+
resetWatchdog();
|
|
166
|
+
});
|
|
103
167
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
168
|
+
socket.on("message", (raw) => {
|
|
169
|
+
resetWatchdog();
|
|
170
|
+
let frame: GateFrame;
|
|
171
|
+
try {
|
|
172
|
+
frame = JSON.parse(raw.toString());
|
|
173
|
+
} catch (_err) {
|
|
174
|
+
this.warn("WS: invalid JSON frame ignored");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!frame || typeof frame !== "object") {
|
|
178
|
+
this.warn("WS: unexpected frame ignored");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
switch (frame.type) {
|
|
182
|
+
case "hello":
|
|
183
|
+
// Подтверждение авторизации — только теперь считаем себя онлайн
|
|
184
|
+
this.debug("WS gateway connected (hello received)");
|
|
185
|
+
reconnectDelay = RECONNECT_MIN_MS; // успешный коннект — сбрасываем backoff
|
|
186
|
+
this.emit('online');
|
|
187
|
+
break;
|
|
188
|
+
case "command":
|
|
189
|
+
// payload — массив capability-объектов, как раньше в MQTT-топике команд
|
|
190
|
+
this.trace("Incoming command devId:" + frame.devId + " timestamp:" + new Date().getTime());
|
|
191
|
+
if (!Array.isArray(frame.payload)) {
|
|
192
|
+
this.warn("WS: command frame without payload array ignored. devId:" + frame.devId);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (frame.devId) {
|
|
196
|
+
// исключение в слушателе не должно убивать обработку кадров на сокете
|
|
197
|
+
try {
|
|
198
|
+
this.emit(String(frame.devId), frame.payload);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
this.warn("WS: command listener error: " + (err instanceof Error ? err.message : String(err)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
case "message":
|
|
205
|
+
// Служебное сообщение от шлюза (замена message-топика)
|
|
206
|
+
if (typeof frame.text === "string") {
|
|
207
|
+
this.warn(frame.text);
|
|
208
|
+
} else {
|
|
209
|
+
this.trace("WS: message frame without text ignored");
|
|
210
|
+
}
|
|
211
|
+
break;
|
|
212
|
+
default:
|
|
213
|
+
this.trace("WS: unknown frame type ignored: " + frame.type);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
107
216
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
217
|
+
// Сервер отверг подключение на этапе upgrade (не 101)
|
|
218
|
+
socket.on("unexpected-response", (req, res) => {
|
|
219
|
+
stopWatchdog();
|
|
220
|
+
if (res.statusCode === 401) {
|
|
221
|
+
this.error("WS gateway rejected token (401): токен недействителен, переавторизуйтесь в настройках ноды");
|
|
222
|
+
} else {
|
|
223
|
+
this.debug("WS unexpected server response: HTTP " + res.statusCode);
|
|
224
|
+
}
|
|
225
|
+
req.destroy();
|
|
226
|
+
if (ws === socket) {
|
|
227
|
+
ws = null;
|
|
228
|
+
}
|
|
229
|
+
this.emit('offline');
|
|
230
|
+
scheduleReconnect(); // backoff не сбрасываем — продолжаем попытки
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
socket.on("error", (err) => {
|
|
234
|
+
// за error всегда следует close — реконнект планируется там
|
|
235
|
+
this.debug("WS error: " + err.message);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
socket.on("close", (code, reason) => {
|
|
239
|
+
stopWatchdog();
|
|
240
|
+
if (code === 4429) {
|
|
241
|
+
this.warn("WS gateway closed connection (4429): превышен лимит подключений — не более 5 на пользователя");
|
|
242
|
+
}
|
|
243
|
+
this.debug("WS connection closed. code:" + code + " reason:" + reason.toString());
|
|
244
|
+
if (ws === socket) {
|
|
245
|
+
ws = null;
|
|
246
|
+
}
|
|
247
|
+
this.emit('offline');
|
|
248
|
+
scheduleReconnect();
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
connect();
|
|
112
253
|
|
|
113
254
|
this.on('offline', () => {
|
|
114
255
|
this.isOnline = false;
|
|
@@ -119,19 +260,66 @@ export = (RED: NodeAPI): void => {
|
|
|
119
260
|
});
|
|
120
261
|
|
|
121
262
|
this.on('close', (done: () => void) => {
|
|
263
|
+
closing = true;
|
|
264
|
+
if (reconnectTimer) {
|
|
265
|
+
clearTimeout(reconnectTimer);
|
|
266
|
+
reconnectTimer = null;
|
|
267
|
+
}
|
|
268
|
+
stopWatchdog();
|
|
122
269
|
this.emit('offline');
|
|
270
|
+
const socket = ws;
|
|
271
|
+
ws = null;
|
|
272
|
+
if (!socket || socket.readyState === WebSocket.CLOSED) {
|
|
273
|
+
done();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// done() зовём по фактическому закрытию сокета: иначе при redeploy новая
|
|
277
|
+
// нода коннектится раньше, чем сервер освободил слот, и ловит 4429
|
|
278
|
+
let doneCalled = false;
|
|
279
|
+
const finish = () => {
|
|
280
|
+
if (!doneCalled) {
|
|
281
|
+
doneCalled = true;
|
|
282
|
+
done();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
socket.once('close', finish);
|
|
286
|
+
try {
|
|
287
|
+
socket.close(1000);
|
|
288
|
+
} catch (_err) {
|
|
289
|
+
socket.terminate();
|
|
290
|
+
}
|
|
291
|
+
// страховка: если сервер не завершил close-хендшейк — рвём сокет и отпускаем done
|
|
123
292
|
setTimeout(() => {
|
|
124
|
-
|
|
125
|
-
|
|
293
|
+
if (socket.readyState !== WebSocket.CLOSED) {
|
|
294
|
+
socket.terminate();
|
|
295
|
+
}
|
|
296
|
+
finish();
|
|
297
|
+
}, CLOSE_FAILSAFE_MS).unref();
|
|
126
298
|
});
|
|
127
299
|
|
|
128
|
-
|
|
300
|
+
// Сигнатура сохранена для совместимости с alice-device:
|
|
301
|
+
// path — старый MQTT-топик ($me/device/events/{devId}), retain не используется
|
|
302
|
+
this.send2gate = (path: string, data: string, _retain: boolean) => {
|
|
129
303
|
this.trace("Outgoing: " + path);
|
|
130
|
-
|
|
304
|
+
const arrPath = path.split('/');
|
|
305
|
+
const devId = arrPath[arrPath.length - 1];
|
|
306
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
307
|
+
// эквивалент MQTT publish с qos 0 в офлайне — молча дропаем
|
|
308
|
+
this.debug("WS not connected, command_result dropped. devId:" + devId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let payload: unknown;
|
|
312
|
+
try {
|
|
313
|
+
payload = JSON.parse(data);
|
|
314
|
+
} catch (_err) {
|
|
315
|
+
this.warn("send2gate: invalid JSON data ignored. devId:" + devId);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
ws.send(JSON.stringify({ v: 1, type: "command_result", devId: devId, payload: payload }));
|
|
131
319
|
};
|
|
132
320
|
|
|
133
321
|
this.getToken = () => {
|
|
134
|
-
return
|
|
322
|
+
return accessToken;
|
|
135
323
|
};
|
|
136
324
|
}
|
|
137
325
|
|
package/CLAUDE.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md
|
|
2
|
-
|
|
3
|
-
Этот файл содержит руководство для Claude Code (claude.ai/code) при работе с кодом в данном репозитории.
|
|
4
|
-
|
|
5
|
-
## Обзор проекта
|
|
6
|
-
|
|
7
|
-
Плагин для Node-RED (`node-red-contrib-alice`), интегрирующий IoT-устройства, управляемые через Node-RED, с голосовым помощником Яндекс Алиса для управления умным домом. Опубликован в npm как `node-red-contrib-alice`.
|
|
8
|
-
|
|
9
|
-
## Команды сборки и разработки
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
npm run build # Компиляция TypeScript (tsc) + копирование HTML из src/ в nodes/
|
|
13
|
-
npm start # Сборка и запуск Node-RED
|
|
14
|
-
npm run copy-html # Копирование src/*.html в nodes/
|
|
15
|
-
```
|
|
16
|
-
|
|
17
|
-
Все исходники (`.ts` + `.html`) находятся в `src/`. Папка `nodes/` — только скомпилированный вывод (JS + скопированные HTML). При `npm run build` TypeScript компилируется в JS, а HTML копируется в `nodes/`.
|
|
18
|
-
|
|
19
|
-
Общие типы и интерфейсы вынесены в `src/types.ts`.
|
|
20
|
-
|
|
21
|
-
## Архитектура
|
|
22
|
-
|
|
23
|
-
### Паттерн нод Node-RED
|
|
24
|
-
|
|
25
|
-
Каждый тип ноды состоит из пары файлов: `.ts` (логика) и `.html` (UI конфигурации):
|
|
26
|
-
|
|
27
|
-
- **Сервисная нода** (`alice.ts`): Центральный узел — управляет OAuth Яндекса, MQTT-подключением к `mqtts://mqtt.cloud.yandex.net:8883`, HTTP-эндпоинтами администрирования, валидацией подписки. Выступает как источник событий для всех нод устройств.
|
|
28
|
-
- **Нода устройства** (`alice-device.ts`): Группирует умения/сенсоры, управляет регистрацией устройства на шлюзе (`api.nodered-home.ru`), дебаунсит обновления состояния (1000мс).
|
|
29
|
-
- **Ноды умений** (`alice-onoff`, `alice-range`, `alice-color`, `alice-mode`, `alice-togle`, `alice-video`): Двунаправленные — принимают голосовые команды от Алисы и отправляют обновления состояния на шлюз.
|
|
30
|
-
- **Нода сенсора** (`alice-sensor`): Передача свойств только для чтения (температура, влажность и т.д.).
|
|
31
|
-
- **Нода событий** (`alice-event`): Обнаруживает изменения состояния и запускает потоки.
|
|
32
|
-
|
|
33
|
-
### Коммуникация
|
|
34
|
-
|
|
35
|
-
- **MQTT**: Команды устройствам в реальном времени через Yandex IoT Cloud
|
|
36
|
-
- **HTTP REST**: Конфигурация устройств и синхронизация состояния со шлюзом `api.nodered-home.ru`
|
|
37
|
-
- **OAuth 2.0**: Аутентификация через Яндекс, обмен токенами через `nodered-home.ru/api/v1/getyatoken`
|
|
38
|
-
|
|
39
|
-
### Структура HTML UI
|
|
40
|
-
|
|
41
|
-
Каждый `.html` файл содержит два блока скриптов:
|
|
42
|
-
1. `<script>` — клиентская логика (регистрация в редакторе Node-RED, REST-вызовы)
|
|
43
|
-
2. `<script type="text/x-red" data-template-name="...">` — шаблон формы
|
|
44
|
-
|
|
45
|
-
### Основные зависимости
|
|
46
|
-
|
|
47
|
-
- `mqtt` — MQTT-клиент для Yandex IoT Cloud
|
|
48
|
-
- `axios` — HTTP-клиент для вызовов API шлюза
|
|
49
|
-
|
|
50
|
-
## Заметки
|
|
51
|
-
|
|
52
|
-
- Опечатка `alice-togle` (вместо `toggle`) — намеренная, это зарегистрированное имя типа ноды
|
|
53
|
-
- В проекте отсутствуют тесты
|
|
54
|
-
- Комментарии в коде и строки UI написаны преимущественно на русском языке
|