node-red-contrib-alice 2.3.5 → 3.0.0

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.
@@ -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.html CHANGED
@@ -1,5 +1,4 @@
1
1
 
2
- <script src="https://widget.cloudpayments.ru/bundles/cloudpayments.js"></script>
3
2
  <script type="text/javascript">
4
3
  RED.nodes.registerType('alice-service',{
5
4
  category: 'config',
@@ -100,63 +99,11 @@
100
99
  })
101
100
  };
102
101
  function pay(id, email) {
103
- console.log("Start pay");
104
102
  if (!id || !email){
105
103
  RED.notify("Please authorize before purchasing a subscription", {type:"error"});
106
104
  return;
107
- };
108
- $('#subscribe-button').prop('disabled', true);
109
- RED.notify("Request has been sent. Please, wait",{type:"compact"});
110
- let paymentWidget = new cp.CloudPayments();
111
- console.log("Start get pay confi");
112
- $.ajax({
113
- url: "https://nodered-home.ru/payment/create",
114
- type:"POST",
115
- headers: {
116
- 'Content-Type':'application/json'
117
- },
118
- crossDomain: true,
119
- contentType:"application/json",
120
- data: JSON.stringify({
121
- id: id,
122
- email: email
123
- }),
124
- dataType: "json",
125
- format:"json"
126
- })
127
- .done(paydata=>{
128
- console.log("pay config done");
129
- console.log("Start widget");
130
- paymentWidget.pay('auth', // или 'charge'
131
- paydata,
132
- {
133
- onSuccess: function (result) { // success
134
- //действие при успешной оплате
135
- let em = $('#node-config-input-email').val();
136
- let idt = $('#node-config-input-id').val();
137
- $('#subscribe-status').text("checking ...");
138
- setTimeout(() => {
139
- getSubscribeStatus(idt,em,"subscribe-status");
140
- }, 2000);
141
-
142
- },
143
- onFail: function (reason, options) { // fail
144
- //действие при неуспешной оплате
145
- },
146
- onComplete: function (paymentResult, options) { //Вызывается как только виджет получает от api.cloudpayments ответ с результатом транзакции.
147
- //например вызов вашей аналитики Facebook Pixel
148
-
149
- }
150
- }
151
- );
152
- $('#subscribe-button').prop('disabled', false);
153
- })
154
- .fail(error=>{
155
- console.log(error)
156
- console.error(error.responseJSON);
157
- RED.notify("Error : "+error.responseJSON.message, {type:"error"});
158
- $('#subscribe-button').prop('disabled', false);
159
- })
105
+ }
106
+ window.open('https://nodered-home.ru/pay.html?id=' + encodeURIComponent(id) + '&email=' + encodeURIComponent(email));
160
107
  };
161
108
  </script>
162
109
 
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.5",
3
+ "version": "3.0.0",
4
4
  "description": "",
5
5
  "scripts": {
6
6
  "start": "npm run build && node-red",
@@ -44,11 +44,12 @@
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
  },
@@ -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.html CHANGED
@@ -1,5 +1,4 @@
1
1
 
2
- <script src="https://widget.cloudpayments.ru/bundles/cloudpayments.js"></script>
3
2
  <script type="text/javascript">
4
3
  RED.nodes.registerType('alice-service',{
5
4
  category: 'config',
@@ -100,63 +99,11 @@
100
99
  })
101
100
  };
102
101
  function pay(id, email) {
103
- console.log("Start pay");
104
102
  if (!id || !email){
105
103
  RED.notify("Please authorize before purchasing a subscription", {type:"error"});
106
104
  return;
107
- };
108
- $('#subscribe-button').prop('disabled', true);
109
- RED.notify("Request has been sent. Please, wait",{type:"compact"});
110
- let paymentWidget = new cp.CloudPayments();
111
- console.log("Start get pay confi");
112
- $.ajax({
113
- url: "https://nodered-home.ru/payment/create",
114
- type:"POST",
115
- headers: {
116
- 'Content-Type':'application/json'
117
- },
118
- crossDomain: true,
119
- contentType:"application/json",
120
- data: JSON.stringify({
121
- id: id,
122
- email: email
123
- }),
124
- dataType: "json",
125
- format:"json"
126
- })
127
- .done(paydata=>{
128
- console.log("pay config done");
129
- console.log("Start widget");
130
- paymentWidget.pay('auth', // или 'charge'
131
- paydata,
132
- {
133
- onSuccess: function (result) { // success
134
- //действие при успешной оплате
135
- let em = $('#node-config-input-email').val();
136
- let idt = $('#node-config-input-id').val();
137
- $('#subscribe-status').text("checking ...");
138
- setTimeout(() => {
139
- getSubscribeStatus(idt,em,"subscribe-status");
140
- }, 2000);
141
-
142
- },
143
- onFail: function (reason, options) { // fail
144
- //действие при неуспешной оплате
145
- },
146
- onComplete: function (paymentResult, options) { //Вызывается как только виджет получает от api.cloudpayments ответ с результатом транзакции.
147
- //например вызов вашей аналитики Facebook Pixel
148
-
149
- }
150
- }
151
- );
152
- $('#subscribe-button').prop('disabled', false);
153
- })
154
- .fail(error=>{
155
- console.log(error)
156
- console.error(error.responseJSON);
157
- RED.notify("Error : "+error.responseJSON.message, {type:"error"});
158
- $('#subscribe-button').prop('disabled', false);
159
- })
105
+ }
106
+ window.open('https://nodered-home.ru/pay.html?id=' + encodeURIComponent(id) + '&email=' + encodeURIComponent(email));
160
107
  };
161
108
  </script>
162
109
 
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 написаны преимущественно на русском языке