padavan 1.0.2 → 2.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.
- package/README.md +104 -77
- package/bin/cli.js +273 -0
- package/package.json +35 -18
- package/src/constants.js +98 -0
- package/src/index.js +723 -0
- package/LICENSE +0 -21
- package/main.mjs +0 -301
- package/services/speedtest.sh +0 -25
package/src/index.js
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
import { debuglog, inspect } from 'util';
|
|
2
|
+
import jszip from 'jszip';
|
|
3
|
+
import HttpClient from './transport/http.js';
|
|
4
|
+
import GitHubClient from './transport/github.js';
|
|
5
|
+
import {
|
|
6
|
+
parsePageInputs, parseNvramOutput, parseLooseJson, normalizeTrafficHistory,
|
|
7
|
+
extractJsVariable, extractTextareaValue, extractMacsFromTextarea, extractCurrentChannel
|
|
8
|
+
} from './utils/parsers.js';
|
|
9
|
+
import { LOG_LEVELS, DEFAULT_LOG_LEVEL, LIB_ID, NVRAM_CACHE_TTL, PAGES, COMMANDS } from './constants.js';
|
|
10
|
+
/** @import { ActionMode, ServiceId, GroupId } from './constants.js' */
|
|
11
|
+
/** @import { Config as HttpConfig } from './transport/http.js' */
|
|
12
|
+
/** @import { Config as GithubConfig } from './transport/github.js' */
|
|
13
|
+
/** @typedef {HttpConfig & GithubConfig} Credentials */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} Config
|
|
17
|
+
* @property {Credentials} [credentials] Учетные данные.
|
|
18
|
+
* @property {('none'|'error'|'warn'|'info'|'debug')} [logLevel='none'] Уровень детализации логов.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} Device
|
|
23
|
+
* @property {string} mac MAC-адрес устройства.
|
|
24
|
+
* @property {string} ip IP-адрес устройства.
|
|
25
|
+
* @property {string|null} hostname Имя хоста.
|
|
26
|
+
* @property {'eth'|'wifi'|'2.4GHz'|'5GHz'} type Тип подключения.
|
|
27
|
+
* @property {number} rssi Уровень сигнала (качество в %).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} WifiNetwork
|
|
32
|
+
* @property {string} ssid Имя сети (SSID).
|
|
33
|
+
* @property {string} bssid MAC-адрес точки доступа (BSSID).
|
|
34
|
+
* @property {number} channel Номер канала.
|
|
35
|
+
* @property {number} quality Качество сигнала.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} ChannelAnalysis
|
|
40
|
+
* @property {number} currentChannel Текущий канал.
|
|
41
|
+
* @property {number} bestChannel Рекомендуемый канал.
|
|
42
|
+
* @property {boolean} isCurrentOptimal Является ли текущий канал оптимальным.
|
|
43
|
+
* @property {number} score Условный рейтинг загруженности рекомендуемого канала (меньше = лучше).
|
|
44
|
+
* @property {Record<number, number>} ratings Рейтинг всех проверенных каналов { канал: штраф }.
|
|
45
|
+
* @property {string} reason Текстовое пояснение рекомендации.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {Object} SetParamsOptions
|
|
50
|
+
* @property {string} [current_page] Текущая страница.
|
|
51
|
+
* @property {string} [next_page] Страница перенаправления.
|
|
52
|
+
* @property {string|ServiceId[]} [sid_list] Строка или массив сервисов для перезапуска.
|
|
53
|
+
* @property {GroupId} [group_id] ID группы.
|
|
54
|
+
* @property {ActionMode} [action_mode=' Apply '] Режим действия.
|
|
55
|
+
* @property {string} [action_script] Имя скрипта.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Основной класс для управления роутером с прошивкой Padavan.
|
|
60
|
+
* Работает через HTTP/Web-интерфейс.
|
|
61
|
+
*/
|
|
62
|
+
export default class Padavan {
|
|
63
|
+
/**
|
|
64
|
+
* Экземпляр debuglog для вывода отладочной информации при NODE_DEBUG=LIB_ID.
|
|
65
|
+
*/
|
|
66
|
+
#debugLog;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Флаг, указывающий, включен ли вывод через NODE_DEBUG=LIB_ID.
|
|
70
|
+
* @type {boolean}
|
|
71
|
+
*/
|
|
72
|
+
#isDebugEnvEnabled = false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Числовой уровень логирования, установленный через конструктор.
|
|
76
|
+
* @type {number}
|
|
77
|
+
*/
|
|
78
|
+
#logLevelNumber = LOG_LEVELS[DEFAULT_LOG_LEVEL];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Экземпляр класса HttpClient.
|
|
82
|
+
* @type {HttpClient|undefined}
|
|
83
|
+
*/
|
|
84
|
+
#http;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Экземпляр класса GitHub.
|
|
88
|
+
* @type {GitHubClient|undefined}
|
|
89
|
+
*/
|
|
90
|
+
#github;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Очередь для последовательного выполнения команд SystemCmd.
|
|
94
|
+
* @type {Promise<any>}
|
|
95
|
+
*/
|
|
96
|
+
#commandQueue = Promise.resolve();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Кэшированный промис запроса NVRAM.
|
|
100
|
+
* @type {Promise<Record<string, string>>|null}
|
|
101
|
+
*/
|
|
102
|
+
#nvramPromise = null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Время последнего запроса NVRAM.
|
|
106
|
+
* @type {number}
|
|
107
|
+
*/
|
|
108
|
+
#nvramTimestamp = 0;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Конфигурация для подключения.
|
|
112
|
+
* @type {Config}
|
|
113
|
+
*/
|
|
114
|
+
config = null;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Конструктор класса Padavan.
|
|
118
|
+
* @param {Config} config Конфигурация для подключения.
|
|
119
|
+
*/
|
|
120
|
+
constructor(config = {}) {
|
|
121
|
+
this.config = config;
|
|
122
|
+
this.#debugLog = debuglog(LIB_ID);
|
|
123
|
+
this.#isDebugEnvEnabled = process.env.NODE_DEBUG && new RegExp(`\\b${LIB_ID}\\b`, 'i').test(process.env.NODE_DEBUG);
|
|
124
|
+
this.#logLevelNumber = LOG_LEVELS[config?.logLevel] || LOG_LEVELS[DEFAULT_LOG_LEVEL];
|
|
125
|
+
this.#http = new HttpClient(this.config?.credentials, this.log.bind(this));
|
|
126
|
+
this.#github = new GitHubClient(this.config?.credentials, this.log.bind(this));
|
|
127
|
+
this.log('debug', 'Padavan instance created with config:', config);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Записывает лог-сообщение.
|
|
132
|
+
* Учитывает logLevel, установленный в конструкторе, и переменную окружения NODE_DEBUG=LIB_ID.
|
|
133
|
+
* @param {('error'|'warn'|'info'|'debug')} level Уровень сообщения.
|
|
134
|
+
* @param {...any} args Аргументы для логирования.
|
|
135
|
+
*/
|
|
136
|
+
log(level, ...args) {
|
|
137
|
+
const prefix = `[${level.toUpperCase()}]`;
|
|
138
|
+
this.#debugLog(prefix, ...args.map(arg => typeof arg === 'object' ? inspect(arg, { depth: null }) : arg));
|
|
139
|
+
if (!this.#isDebugEnvEnabled && (LOG_LEVELS[level] <= this.#logLevelNumber))
|
|
140
|
+
console[level](`[${LIB_ID}]`, prefix, ...args);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Выполняет системную команду через эмулятор консоли (HTTP).
|
|
145
|
+
* @param {string} command Команда (например, 'ls -la' или 'nvram show').
|
|
146
|
+
* @returns {Promise<string>} Вывод команды (stdout + stderr).
|
|
147
|
+
*/
|
|
148
|
+
async exec(command) {
|
|
149
|
+
const result = this.#commandQueue.then(async () => {
|
|
150
|
+
this.log('debug', `Executing command: ${command}`);
|
|
151
|
+
if (!this.#http)
|
|
152
|
+
throw new Error('HTTP client not initialized');
|
|
153
|
+
try {
|
|
154
|
+
await this.#http.post(PAGES.APPLY, {
|
|
155
|
+
action_mode: ' SystemCmd ',
|
|
156
|
+
SystemCmd: command
|
|
157
|
+
});
|
|
158
|
+
const response = await this.#http.get(PAGES.CONSOLE_RESPONSE);
|
|
159
|
+
return response.trim();
|
|
160
|
+
} catch (e) {
|
|
161
|
+
this.log('error', `Command execution failed: ${command}`, e);
|
|
162
|
+
throw e;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
this.#commandQueue = result.catch(() => {});
|
|
166
|
+
return result;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Получает параметры роутера.
|
|
171
|
+
* Если указана страница, парсит HTML-поля ввода. Иначе выполняет `nvram show`.
|
|
172
|
+
* @param {string|string[]} [keys] Имя параметра или массив имен для фильтрации.
|
|
173
|
+
* @param {string} [page] Если указана страница (напр. 'Advanced_DHCP_Content.asp'), парсит её HTML.
|
|
174
|
+
* @returns {Promise<Record<string, string>|string>} Объект с параметрами или значение конкретного параметра.
|
|
175
|
+
*/
|
|
176
|
+
async getParams(keys, page = null) {
|
|
177
|
+
let /** @type {Record<string, string>} */ allParams = {};
|
|
178
|
+
if (page) {
|
|
179
|
+
this.log('debug', `Fetching params from page: ${page}`);
|
|
180
|
+
const html = await this.#http.get(page);
|
|
181
|
+
allParams = parsePageInputs(html);
|
|
182
|
+
} else {
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
if (this.#nvramPromise && ((now - this.#nvramTimestamp) < NVRAM_CACHE_TTL)) {
|
|
185
|
+
this.log('debug', 'Using cached NVRAM data');
|
|
186
|
+
allParams = await this.#nvramPromise;
|
|
187
|
+
} else {
|
|
188
|
+
this.log('debug', 'Fetching params via NVRAM...');
|
|
189
|
+
this.#nvramTimestamp = now;
|
|
190
|
+
this.#nvramPromise = this.exec(COMMANDS.NVRAM_SHOW).then(parseNvramOutput);
|
|
191
|
+
this.#nvramPromise.catch(() => {
|
|
192
|
+
this.#nvramPromise = null;
|
|
193
|
+
});
|
|
194
|
+
allParams = await this.#nvramPromise;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!keys)
|
|
198
|
+
return allParams;
|
|
199
|
+
if (typeof keys === 'string')
|
|
200
|
+
return allParams[keys];
|
|
201
|
+
if (Array.isArray(keys)) {
|
|
202
|
+
const /** @type {Record<string, string>} */ result = {};
|
|
203
|
+
keys.forEach(k => {
|
|
204
|
+
if (allParams[k] !== undefined)
|
|
205
|
+
result[k] = allParams[k];
|
|
206
|
+
});
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
return allParams;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Устанавливает параметры роутера.
|
|
214
|
+
* Автоматически находит sid_list, если указан current_page.
|
|
215
|
+
* @param {Record<string, string|number>} params Параметры { key: value }.
|
|
216
|
+
* @param {SetParamsOptions} [options] Опции.
|
|
217
|
+
* @returns {Promise<boolean>}
|
|
218
|
+
*/
|
|
219
|
+
async setParams(params, options = {}) {
|
|
220
|
+
const kvPairs = Object.entries(params);
|
|
221
|
+
if (kvPairs.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
const page = options.current_page || options.next_page;
|
|
224
|
+
|
|
225
|
+
let sidList = options.sid_list;
|
|
226
|
+
if (Array.isArray(sidList))
|
|
227
|
+
sidList = sidList.join(';') + ';';
|
|
228
|
+
else if (!sidList && page) {
|
|
229
|
+
this.log('debug', `sid_list not provided, searching on ${page}...`);
|
|
230
|
+
try {
|
|
231
|
+
const html = await this.#http.get(page);
|
|
232
|
+
const pageInputs = parsePageInputs(html);
|
|
233
|
+
if (pageInputs['sid_list']) {
|
|
234
|
+
sidList = pageInputs['sid_list'];
|
|
235
|
+
this.log('debug', `Found sid_list: ${sidList}`);
|
|
236
|
+
} else
|
|
237
|
+
this.log('warn', `Could not find sid_list on ${page}`);
|
|
238
|
+
} catch (e) {
|
|
239
|
+
this.log('warn', `Failed to fetch ${page} for sid_list extraction`, e);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
this.log('info', `Setting params: ${Object.keys(params).join(', ')}`);
|
|
244
|
+
this.#nvramPromise = null;
|
|
245
|
+
this.#nvramTimestamp = 0;
|
|
246
|
+
|
|
247
|
+
if (sidList) {
|
|
248
|
+
const data = {
|
|
249
|
+
action_mode: options.action_mode || ' Apply ',
|
|
250
|
+
action_script: options.action_script || '',
|
|
251
|
+
sid_list: sidList,
|
|
252
|
+
group_id: options.group_id || '',
|
|
253
|
+
current_page: page || 'index.asp',
|
|
254
|
+
next_page: options.next_page || page || 'index.asp',
|
|
255
|
+
...params
|
|
256
|
+
};
|
|
257
|
+
try {
|
|
258
|
+
await this.#http.post(PAGES.APPLY, data);
|
|
259
|
+
return true;
|
|
260
|
+
} catch (e) {
|
|
261
|
+
this.log('debug', 'setParams (UI) finished', e.message);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this.log('warn', 'sid_list not found/provided. Using NVRAM fallback (requires reboot to apply).');
|
|
267
|
+
const commands = kvPairs.map(([k, v]) => {
|
|
268
|
+
const safeValue = String(v).replace(/'/g, "'\\''");
|
|
269
|
+
return `nvram set ${k}='${safeValue}'`;
|
|
270
|
+
});
|
|
271
|
+
commands.push('nvram commit');
|
|
272
|
+
|
|
273
|
+
await this.exec(commands.join('; '));
|
|
274
|
+
return true;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Получает текущий статус системы (CPU, RAM, Uptime, LoadAvg).
|
|
279
|
+
* @returns {Promise<Object>}
|
|
280
|
+
*/
|
|
281
|
+
async getStatus() {
|
|
282
|
+
this.log('debug', 'Fetching system status...');
|
|
283
|
+
try {
|
|
284
|
+
const html = await this.#http.get(PAGES.STATUS);
|
|
285
|
+
const status = parseLooseJson(html);
|
|
286
|
+
if (!status)
|
|
287
|
+
throw new Error('Failed to parse system status data');
|
|
288
|
+
return status;
|
|
289
|
+
} catch (e) {
|
|
290
|
+
this.log('error', 'getStatus failed:', e);
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Получает историю трафика (ежедневную и ежемесячную).
|
|
297
|
+
* @returns {Promise<{daily: any[], monthly: any[]}>}
|
|
298
|
+
*/
|
|
299
|
+
async getHistory() {
|
|
300
|
+
this.log('debug', 'Fetching traffic history...');
|
|
301
|
+
try {
|
|
302
|
+
const html = await this.#http.get(PAGES.TRAFFIC);
|
|
303
|
+
const rawDaily = extractJsVariable(html, 'daily_history');
|
|
304
|
+
const rawMonthly = extractJsVariable(html, 'monthly_history');
|
|
305
|
+
return {
|
|
306
|
+
daily: normalizeTrafficHistory(rawDaily),
|
|
307
|
+
monthly: normalizeTrafficHistory(rawMonthly)
|
|
308
|
+
};
|
|
309
|
+
} catch (e) {
|
|
310
|
+
this.log('error', 'getHistory failed:', e);
|
|
311
|
+
throw e;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Получает системный журнал.
|
|
317
|
+
* @returns {Promise<string>} Текст лога.
|
|
318
|
+
*/
|
|
319
|
+
async getLog() {
|
|
320
|
+
this.log('debug', 'Fetching system log...');
|
|
321
|
+
if (!this.#http) throw new Error('HTTP client not initialized');
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const html = await this.#http.get(PAGES.SYSLOG);
|
|
325
|
+
return extractTextareaValue(html);
|
|
326
|
+
} catch (e) {
|
|
327
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
328
|
+
this.log('error', 'getLog failed:', err);
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Получает список подключенных устройств.
|
|
335
|
+
* @returns {Promise<Device[]>}
|
|
336
|
+
*/
|
|
337
|
+
async getDevices() {
|
|
338
|
+
this.log('debug', 'Fetching devices info via HTTP...');
|
|
339
|
+
const [clientsHtml, wifi2gHtml, wifi5gHtml] = await Promise.all([
|
|
340
|
+
this.#http.get(PAGES.CLIENTS),
|
|
341
|
+
this.#http.get(PAGES.WIFI_2G),
|
|
342
|
+
this.#http.get(PAGES.WIFI_5G)
|
|
343
|
+
]);
|
|
344
|
+
const ipmonitor = extractJsVariable(clientsHtml, 'ipmonitor') || [];
|
|
345
|
+
const wirelessObj = extractJsVariable(clientsHtml, 'wireless') || {};
|
|
346
|
+
|
|
347
|
+
const macs2g = new Set(extractMacsFromTextarea(wifi2gHtml));
|
|
348
|
+
const macs5g = new Set(extractMacsFromTextarea(wifi5gHtml));
|
|
349
|
+
const wirelessMacs = new Set(Object.keys(wirelessObj));
|
|
350
|
+
|
|
351
|
+
return ipmonitor.map((/** @type {string[]} */ item) => {
|
|
352
|
+
if (!item[1] || item[5] === '1')
|
|
353
|
+
return null;
|
|
354
|
+
const ip = item[0];
|
|
355
|
+
const mac = item[1].toUpperCase();
|
|
356
|
+
const hostname = item[2];
|
|
357
|
+
const rssi = wirelessObj[mac] || null;
|
|
358
|
+
|
|
359
|
+
let /** @type {Device['type']} */ type = 'eth';
|
|
360
|
+
if (wirelessMacs.has(mac) || macs2g.has(mac) || macs5g.has(mac)) {
|
|
361
|
+
if (macs5g.has(mac))
|
|
362
|
+
type = '5GHz';
|
|
363
|
+
else if (macs2g.has(mac))
|
|
364
|
+
type = '2.4GHz';
|
|
365
|
+
else
|
|
366
|
+
type = 'wifi';
|
|
367
|
+
}
|
|
368
|
+
return { mac, ip, hostname, type, rssi };
|
|
369
|
+
}).filter(Boolean);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Сканирование эфира.
|
|
374
|
+
* ВНИМАНИЕ: Если вы подключены по Wi-Fi к сканируемому диапазону, соединение разорвется.
|
|
375
|
+
* @param {'2.4'|'5'} [band='2.4'] Частотный диапазон.
|
|
376
|
+
* @returns {Promise<WifiNetwork[]>} Список найденных сетей, отсортированный по уровню сигнала.
|
|
377
|
+
*/
|
|
378
|
+
async startScan(band = '2.4') {
|
|
379
|
+
this.log('info', `Starting Site Survey for ${band}GHz...`);
|
|
380
|
+
const page = band === '5' ? PAGES.SCAN_5G : PAGES.SCAN_2G;
|
|
381
|
+
let html;
|
|
382
|
+
try {
|
|
383
|
+
html = await this.#http.get(page);
|
|
384
|
+
} catch (e) {
|
|
385
|
+
this.log('error', `Scan failed due to connection loss. Are you connected via Wi-Fi to the same band?`, e.message);
|
|
386
|
+
throw new Error('Connection lost during scan. Cannot retrieve results via Wi-Fi.');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const rawList = extractJsVariable(html, 'wds_aplist');
|
|
390
|
+
if (!Array.isArray(rawList)) {
|
|
391
|
+
this.log('warn', 'Scan returned empty or invalid data');
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return rawList.map(item => {
|
|
396
|
+
const ssid = decodeURIComponent(item[0]);
|
|
397
|
+
const bssid = item[1].toUpperCase();
|
|
398
|
+
const channel = parseInt(item[2], 10);
|
|
399
|
+
const quality = parseInt(item[3], 10);
|
|
400
|
+
if (ssid === '???' || !quality || quality < 1 || !channel || !bssid)
|
|
401
|
+
return null;
|
|
402
|
+
return { ssid, bssid, channel, quality };
|
|
403
|
+
}).filter(Boolean).sort((a, b) => b.quality - a.quality);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Анализирует эфир и предлагает лучший канал с учетом совместимости и региона.
|
|
408
|
+
* @param {'2.4'|'5'} [band='2.4'] Частотный диапазон.
|
|
409
|
+
* @param {WifiNetwork[]} [scanResults] Опционально: результаты сканирования.
|
|
410
|
+
* @returns {Promise<ChannelAnalysis>} Результат анализа.
|
|
411
|
+
*/
|
|
412
|
+
async getBestChannel(band = '2.4', scanResults = null) {
|
|
413
|
+
const is24 = band === '2.4';
|
|
414
|
+
const configPrefix = is24 ? 'rt' : 'wl';
|
|
415
|
+
const statusPage = is24 ? PAGES.WIFI_2G : PAGES.WIFI_5G;
|
|
416
|
+
|
|
417
|
+
const [settings, statusHtml] = await Promise.all([
|
|
418
|
+
this.getParams([`${configPrefix}_channel`, `${configPrefix}_country_code`]),
|
|
419
|
+
this.#http.get(statusPage)
|
|
420
|
+
]);
|
|
421
|
+
|
|
422
|
+
const configuredChannel = parseInt(settings[`${configPrefix}_channel`] || '0', 10);
|
|
423
|
+
const countryCode = settings[`${configPrefix}_country_code`] || 'DB';
|
|
424
|
+
|
|
425
|
+
let currentChannel = configuredChannel;
|
|
426
|
+
if (currentChannel === 0) {
|
|
427
|
+
const logText = extractTextareaValue(statusHtml);
|
|
428
|
+
currentChannel = extractCurrentChannel(logText);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let /** @type {number[]} */ validChannels = [];
|
|
432
|
+
let /** @type {number[]} */ safeChannels = [];
|
|
433
|
+
|
|
434
|
+
if (is24) {
|
|
435
|
+
const maxCh = (countryCode === 'US' || countryCode === 'CA') ? 11 : 13;
|
|
436
|
+
for (let i = 1; i <= maxCh; i++)
|
|
437
|
+
validChannels.push(i);
|
|
438
|
+
safeChannels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
|
439
|
+
} else {
|
|
440
|
+
validChannels = [36, 40, 44, 48];
|
|
441
|
+
safeChannels = [...validChannels];
|
|
442
|
+
|
|
443
|
+
const dfsChannels = [52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144];
|
|
444
|
+
const highChannels = [149, 153, 157, 161, 165];
|
|
445
|
+
|
|
446
|
+
if (['RU', 'US', 'CN', 'SG', 'AU', 'DB'].includes(countryCode)) {
|
|
447
|
+
validChannels.push(...highChannels);
|
|
448
|
+
safeChannels.push(...highChannels);
|
|
449
|
+
}
|
|
450
|
+
if (countryCode !== 'DB')
|
|
451
|
+
validChannels.push(...dfsChannels);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (validChannels.length === 0)
|
|
455
|
+
return {
|
|
456
|
+
currentChannel,
|
|
457
|
+
bestChannel: 0,
|
|
458
|
+
isCurrentOptimal: false,
|
|
459
|
+
score: 0,
|
|
460
|
+
ratings: {},
|
|
461
|
+
reason: 'No channels available in this region'
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const /** @type {Record<number, number>} */ channelScores = {};
|
|
465
|
+
validChannels.forEach(ch => channelScores[ch] = 0);
|
|
466
|
+
|
|
467
|
+
const networks = scanResults || await this.startScan(band);
|
|
468
|
+
networks.forEach(net => {
|
|
469
|
+
const penalty = net.quality;
|
|
470
|
+
if (is24) {
|
|
471
|
+
for (let offset = -4; offset <= 4; offset++) {
|
|
472
|
+
const targetCh = net.channel + offset;
|
|
473
|
+
if (channelScores[targetCh] !== undefined) {
|
|
474
|
+
const weight = 1 - (Math.abs(offset) * 0.2);
|
|
475
|
+
if (weight > 0)
|
|
476
|
+
channelScores[targetCh] += penalty * weight;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
} else {
|
|
480
|
+
if (channelScores[net.channel] !== undefined)
|
|
481
|
+
channelScores[net.channel] += penalty;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Штрафы за специфические типы каналов
|
|
486
|
+
const PENALTY_UNSAFE = 25; // Для каналов с меньшей совместимостью (12-13)
|
|
487
|
+
const PENALTY_DFS = 15; // Для DFS-каналов (требуют детекции радаров)
|
|
488
|
+
|
|
489
|
+
validChannels.forEach(ch => {
|
|
490
|
+
if (is24) {
|
|
491
|
+
if (!safeChannels.includes(ch))
|
|
492
|
+
channelScores[ch] += PENALTY_UNSAFE;
|
|
493
|
+
} else {
|
|
494
|
+
if (ch >= 52 && ch <= 144)
|
|
495
|
+
channelScores[ch] += PENALTY_DFS;
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
let bestChannel = validChannels[0];
|
|
500
|
+
let minScore = Infinity;
|
|
501
|
+
|
|
502
|
+
for (const ch of validChannels) {
|
|
503
|
+
channelScores[ch] = Math.round(channelScores[ch]);
|
|
504
|
+
if (channelScores[ch] < minScore) {
|
|
505
|
+
minScore = channelScores[ch];
|
|
506
|
+
bestChannel = ch;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let isCurrentOptimal = false;
|
|
511
|
+
let reason = '';
|
|
512
|
+
|
|
513
|
+
if (validChannels.includes(currentChannel)) {
|
|
514
|
+
const currentScore = channelScores[currentChannel];
|
|
515
|
+
const improvementThreshold = Math.max(currentScore * 0.2, 15);
|
|
516
|
+
|
|
517
|
+
if (currentChannel === bestChannel) {
|
|
518
|
+
isCurrentOptimal = true;
|
|
519
|
+
reason = 'Current channel is optimal';
|
|
520
|
+
} else if ((currentScore - minScore) < improvementThreshold) {
|
|
521
|
+
bestChannel = currentChannel;
|
|
522
|
+
minScore = currentScore;
|
|
523
|
+
isCurrentOptimal = true;
|
|
524
|
+
reason = 'Current channel is OK, no need to change';
|
|
525
|
+
} else
|
|
526
|
+
reason = `Better channel found (interference: ${minScore} < ${currentScore})`;
|
|
527
|
+
} else
|
|
528
|
+
reason = 'Current channel is auto or not supported';
|
|
529
|
+
|
|
530
|
+
if (is24 && !isCurrentOptimal) {
|
|
531
|
+
const standardChannels = [1, 6, 11];
|
|
532
|
+
const availableStandard = standardChannels.find(c => validChannels.includes(c));
|
|
533
|
+
if (availableStandard && !standardChannels.includes(bestChannel)) {
|
|
534
|
+
if (channelScores[availableStandard] <= minScore * 1.1) {
|
|
535
|
+
bestChannel = availableStandard;
|
|
536
|
+
minScore = channelScores[availableStandard];
|
|
537
|
+
reason = `Channel ${availableStandard} selected (less interference with neighbors)`;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
currentChannel,
|
|
544
|
+
bestChannel,
|
|
545
|
+
isCurrentOptimal,
|
|
546
|
+
score: minScore,
|
|
547
|
+
ratings: channelScores,
|
|
548
|
+
reason
|
|
549
|
+
};
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Перезагрузка роутера через HTTP.
|
|
554
|
+
* @returns {Promise<boolean>}
|
|
555
|
+
*/
|
|
556
|
+
async startReboot() {
|
|
557
|
+
this.log('warn', 'Rebooting router via HTTP...');
|
|
558
|
+
try {
|
|
559
|
+
await this.#http.post(PAGES.APPLY, {
|
|
560
|
+
action_mode: ' Reboot '
|
|
561
|
+
});
|
|
562
|
+
} catch (e) {
|
|
563
|
+
this.log('debug', 'Reboot request sent (network error expected)', e.message);
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Запускает сборку прошивки в GitHub репозитории.
|
|
570
|
+
* Требует наличия `repo`, `branch`, `token` в credentials.
|
|
571
|
+
* @param {string} [workflowName='build.yml'] Имя файла workflow в `.github/workflows/`.
|
|
572
|
+
* @returns {Promise<boolean>}
|
|
573
|
+
*/
|
|
574
|
+
async startBuild(workflowName = 'build.yml') {
|
|
575
|
+
this.log('info', `Triggering build in repository...`);
|
|
576
|
+
try {
|
|
577
|
+
await this.#github.startBuild(workflowName);
|
|
578
|
+
this.log('info', 'Build successfully triggered.');
|
|
579
|
+
return true;
|
|
580
|
+
} catch (e) {
|
|
581
|
+
this.log('error', 'Failed to start build:', e);
|
|
582
|
+
throw e;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Получает список изменений (changelog) между текущей прошивкой и последней доступной.
|
|
588
|
+
* @returns {Promise<{from: string, to: string, messages: string[]}>}
|
|
589
|
+
*/
|
|
590
|
+
async getChangelog() {
|
|
591
|
+
this.log('debug', 'Fetching changelog...');
|
|
592
|
+
try {
|
|
593
|
+
const currentFirmware = /** @type {string} */ (await this.getParams('firmver_sub') || '');
|
|
594
|
+
const fromId = (currentFirmware.split('_')[1] || '').substring(0, 7);
|
|
595
|
+
|
|
596
|
+
const artifact = await this.#github.getLatestArtifact();
|
|
597
|
+
const toIdMatch = artifact.name.match(/-([0-9a-f]{7,})$/);
|
|
598
|
+
const toId = toIdMatch ? toIdMatch[1]?.substring(0, 7) : null;
|
|
599
|
+
console.log({
|
|
600
|
+
currentFirmware, fromId,
|
|
601
|
+
artifact,
|
|
602
|
+
toIdMatch, toId
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
if (!fromId || !toId)
|
|
606
|
+
throw new Error(`Could not determine firmware versions (current: ${fromId}, latest: ${toId})`);
|
|
607
|
+
|
|
608
|
+
if (fromId === toId) {
|
|
609
|
+
this.log('info', 'Firmware is up to date.');
|
|
610
|
+
return { from: fromId, to: toId, messages: [] };
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
this.log('debug', 'Fetching build.conf to determine source repo...');
|
|
614
|
+
const buildConf = await this.#github.getFileContent('build.conf');
|
|
615
|
+
|
|
616
|
+
const repoMatch = buildConf.match(/^PADAVAN_REPO=["'](.*?)["']/m);
|
|
617
|
+
if (!repoMatch)
|
|
618
|
+
throw new Error('PADAVAN_REPO not found in build.conf');
|
|
619
|
+
let sourceRepoUrl = repoMatch[1];
|
|
620
|
+
this.log('debug', `Source repo identified: ${sourceRepoUrl}`);
|
|
621
|
+
|
|
622
|
+
const messages = await this.#github.getCommitsBetween(sourceRepoUrl, fromId, toId);
|
|
623
|
+
return { from: fromId, to: toId, messages };
|
|
624
|
+
} catch (e) {
|
|
625
|
+
this.log('error', 'Failed to get changelog:', e);
|
|
626
|
+
throw e;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Устанавливает последнюю доступную прошивку из репозитория.
|
|
632
|
+
* @returns {Promise<boolean>}
|
|
633
|
+
*/
|
|
634
|
+
async startUpgrade() {
|
|
635
|
+
this.log('warn', `Starting firmware upgrade process...`);
|
|
636
|
+
|
|
637
|
+
const artifact = await this.#github.getLatestArtifact();
|
|
638
|
+
this.log('info', `Found latest firmware artifact: ${artifact.name}`);
|
|
639
|
+
|
|
640
|
+
const buffer = await this.#github.downloadArtifact(artifact.id);
|
|
641
|
+
this.log('debug', `Downloaded ${buffer.byteLength} bytes.`);
|
|
642
|
+
|
|
643
|
+
const zip = await jszip.loadAsync(buffer);
|
|
644
|
+
const firmwareFile = Object.values(zip.files).find(f => f.name.match(/\.(bin|trx)$/));
|
|
645
|
+
if (!firmwareFile)
|
|
646
|
+
throw new Error('Firmware file (.bin or .trx) not found in artifact');
|
|
647
|
+
|
|
648
|
+
this.log('info', `Found firmware file in archive: ${firmwareFile.name}`);
|
|
649
|
+
|
|
650
|
+
const firmwareBlob = await firmwareFile.async('blob');
|
|
651
|
+
const formData = new FormData();
|
|
652
|
+
formData.append('file', firmwareBlob, firmwareFile.name);
|
|
653
|
+
|
|
654
|
+
this.log('info', `Uploading ${firmwareFile.name} to the router...`);
|
|
655
|
+
const res = await this.#http.postFile(PAGES.UPGRADE, formData);
|
|
656
|
+
if (res.includes('showUpgradeBar')) {
|
|
657
|
+
this.log('info', 'Firmware uploaded successfully. Router is now flashing.');
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
this.log('error', 'Firmware upload failed. Router response did not contain success marker.');
|
|
662
|
+
throw new Error('Firmware upload failed');
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Ищет прошивки для текущей (или указанной) модели в сети форков GitHub.
|
|
667
|
+
* @param {string} [modelName] Название модели. Если нет - берется из NVRAM.
|
|
668
|
+
* @returns {Promise<any[]>}
|
|
669
|
+
*/
|
|
670
|
+
async findFirmware(modelName) {
|
|
671
|
+
if (!modelName) {
|
|
672
|
+
this.log('debug', 'Model name not provided, fetching from NVRAM...');
|
|
673
|
+
try {
|
|
674
|
+
modelName = /** @type {string} */ (await this.getParams('productid'));
|
|
675
|
+
} catch (e) {
|
|
676
|
+
throw new Error('Connect to router failed. Please specify --model manually.');
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (!modelName)
|
|
680
|
+
throw new Error('Could not determine router model');
|
|
681
|
+
|
|
682
|
+
let startRepo = this.config.credentials?.repo;
|
|
683
|
+
if (!startRepo)
|
|
684
|
+
throw new Error('Repo not specified via --repo and not found in config');
|
|
685
|
+
|
|
686
|
+
this.log('info', `Searching firmware for ${modelName} starting from ${startRepo}...`);
|
|
687
|
+
|
|
688
|
+
const repoInfo = await this.#github.getRepoInfo(startRepo);
|
|
689
|
+
const sourceRepo = repoInfo.source ? repoInfo.source.full_name : (repoInfo.full_name || startRepo);
|
|
690
|
+
this.log('debug', `Source repository identified as: ${sourceRepo}`);
|
|
691
|
+
|
|
692
|
+
const forks = await this.#github.getForks(`https://api.github.com/repos/${sourceRepo}`);
|
|
693
|
+
forks.unshift({ full_name: sourceRepo });
|
|
694
|
+
this.log('info', `Found ${forks.length} repositories. Scanning artifacts...`);
|
|
695
|
+
|
|
696
|
+
const results = [];
|
|
697
|
+
const chunkSize = 5;
|
|
698
|
+
for (let i = 0; i < forks.length; i += chunkSize) {
|
|
699
|
+
const chunk = forks.slice(i, i + chunkSize);
|
|
700
|
+
await Promise.all(chunk.map(async (fork) => {
|
|
701
|
+
const artifacts = await this.#github.getRepoArtifacts(fork.full_name);
|
|
702
|
+
const matched = artifacts.filter(a => !a.expired && a.name.toLowerCase().includes(modelName.toLowerCase()));
|
|
703
|
+
const uniqueByBranch = {};
|
|
704
|
+
matched.forEach(a => {
|
|
705
|
+
const branch = a.workflow_run?.head_branch || 'unknown';
|
|
706
|
+
if (!uniqueByBranch[branch])
|
|
707
|
+
uniqueByBranch[branch] = a;
|
|
708
|
+
});
|
|
709
|
+
Object.values(uniqueByBranch).forEach((/** @type {any} */ art) => {
|
|
710
|
+
results.push({
|
|
711
|
+
repo: fork.full_name,
|
|
712
|
+
branch: art.workflow_run?.head_branch,
|
|
713
|
+
name: art.name,
|
|
714
|
+
created_at: art.created_at,
|
|
715
|
+
size: art.size_in_bytes,
|
|
716
|
+
active: !art.expired
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
}));
|
|
720
|
+
}
|
|
721
|
+
return results.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
|
722
|
+
};
|
|
723
|
+
};
|