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 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/))
@@ -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 mqtt_1 = __importDefault(require("mqtt"));
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
- const mqttClient = mqtt_1.default.connect("mqtts://mqtt.cloud.yandex.net", {
59
- port: 8883,
60
- clientId: login,
61
- rejectUnauthorized: false,
62
- username: login,
63
- password: password,
64
- reconnectPeriod: 10000
65
- });
66
- mqttClient.on("message", (topic, payload) => {
67
- const arrTopic = topic.split('/');
68
- const data = JSON.parse(payload.toString());
69
- this.trace("Incoming:" + topic + " timestamp:" + new Date().getTime());
70
- if (payload.length && typeof data === 'object') {
71
- if (arrTopic[3] == 'message') {
72
- this.warn(data.text);
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.emit(arrTopic[3], data);
177
+ this.debug("WS unexpected server response: HTTP " + res.statusCode);
76
178
  }
77
- }
78
- });
79
- mqttClient.on("connect", () => {
80
- this.debug("Yandex IOT client connected. ");
81
- this.emit('online');
82
- mqttClient.subscribe("$me/device/commands/+", () => {
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
- mqttClient.on("offline", () => {
87
- this.debug("Yandex IOT client offline. ");
88
- this.emit('offline');
89
- });
90
- mqttClient.on("disconnect", () => {
91
- this.debug("Yandex IOT client disconnect.");
92
- this.emit('offline');
93
- });
94
- mqttClient.on("reconnect", () => {
95
- this.debug("Yandex IOT client reconnecting ...");
96
- });
97
- mqttClient.on("error", (err) => {
98
- this.error("Yandex IOT client Error: " + err.message);
99
- this.emit('offline');
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
- mqttClient.end(false, {}, done);
111
- }, 500);
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, retain) => {
244
+ this.send2gate = (path, data, _retain) => {
114
245
  this.trace("Outgoing: " + path);
115
- mqttClient.publish(path, data, { qos: 0, retain: retain });
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 JSON.parse(token).access_token;
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": "2.3.6",
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
- "mqtt": "^4.3.8"
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": [".git", "test"],
57
+ "ignoreRoot": [
58
+ ".git",
59
+ "test"
60
+ ],
57
61
  "restartable": "rs",
58
62
  "ignore": [
59
63
  "node_modules/**/node_modules",
@@ -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 mqtt from "mqtt";
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
- const mqttClient = mqtt.connect("mqtts://mqtt.cloud.yandex.net", {
65
- port: 8883,
66
- clientId: login,
67
- rejectUnauthorized: false,
68
- username: login,
69
- password: password,
70
- reconnectPeriod: 10000
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
- mqttClient.on("message", (topic: string, payload: Buffer) => {
74
- const arrTopic = topic.split('/');
75
- const data = JSON.parse(payload.toString());
76
- this.trace("Incoming:" + topic + " timestamp:" + new Date().getTime());
77
- if (payload.length && typeof data === 'object') {
78
- if (arrTopic[3] == 'message') {
79
- this.warn(data.text);
80
- } else {
81
- this.emit(arrTopic[3], data);
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
- mqttClient.on("connect", () => {
87
- this.debug("Yandex IOT client connected. ");
88
- this.emit('online');
89
- mqttClient.subscribe("$me/device/commands/+", () => {
90
- this.debug("Yandex IOT client subscribed to the command");
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
- mqttClient.on("offline", () => {
95
- this.debug("Yandex IOT client offline. ");
96
- this.emit('offline');
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
- mqttClient.on("disconnect", () => {
100
- this.debug("Yandex IOT client disconnect.");
101
- this.emit('offline');
102
- });
163
+ // Стандартный WS ping от сервера (pong библиотека ws шлёт сама)
164
+ socket.on("ping", () => {
165
+ resetWatchdog();
166
+ });
103
167
 
104
- mqttClient.on("reconnect", () => {
105
- this.debug("Yandex IOT client reconnecting ...");
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
- mqttClient.on("error", (err) => {
109
- this.error("Yandex IOT client Error: " + err.message);
110
- this.emit('offline');
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
- mqttClient.end(false, {}, done);
125
- }, 500);
293
+ if (socket.readyState !== WebSocket.CLOSED) {
294
+ socket.terminate();
295
+ }
296
+ finish();
297
+ }, CLOSE_FAILSAFE_MS).unref();
126
298
  });
127
299
 
128
- this.send2gate = (path: string, data: string, retain: boolean) => {
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
- mqttClient.publish(path, data, { qos: 0, retain: retain });
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 JSON.parse(token).access_token;
322
+ return accessToken;
135
323
  };
136
324
  }
137
325
 
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git remote -v && git config --list --local 2>/dev/null)",
5
- "Bash(git config:*)",
6
- "Bash(ssh -T git@github.com)",
7
- "Bash(git remote:*)",
8
- "Bash(git push:*)"
9
- ]
10
- }
11
- }
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 написаны преимущественно на русском языке