@iola_adm/iola-cli 0.2.48 → 0.2.50

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 CHANGED
@@ -210,12 +210,15 @@ Yandex tools уже доступны: профиль Yandex ID, расширен
210
210
  Мой домофон:
211
211
 
212
212
  ```bash
213
+ iola ufanet
213
214
  iola ufanet setup
214
215
  iola ufanet intercoms
215
216
  iola ufanet open
216
217
  iola ufanet open ID
217
218
  iola ufanet history
218
219
  iola ufanet cameras
220
+ iola ufanet camera open НОМЕР
221
+ iola ufanet camera snapshot НОМЕР
219
222
  iola ufanet watch
220
223
  iola ufanet notifications on
221
224
  iola ufanet notifications off
@@ -223,7 +226,7 @@ iola dom_ru
223
226
  iola rostelecom
224
227
  ```
225
228
 
226
- Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, камеры/RTSP и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Команда `открой домофон` открывает единственный домофон сразу; если домофонов несколько, CLI просит выбрать адрес цифрой. При уведомлении о вызове можно ответить `да`, `да открой` или `нет`. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
229
+ `/ufanet` в интерактивном CLI открывает отдельное меню домофона с выбором действия цифрой. Уфанет поддерживается как рабочий провайдер: список домофонов, открытие двери после подтверждения, история звонков, записи звонков, безопасный список камер без вывода RTSP-токенов, открытие видео во внешнем плеере, локальный снимок камеры через ffmpeg и уведомления о новых вызовах через опрос истории звонков, пока CLI открыт. Команда `открой домофон` открывает единственный домофон сразу; если домофонов несколько, CLI просит выбрать адрес цифрой. При уведомлении о вызове можно ответить `да`, `да открой` или `нет`. Дом.ру и Ростелеком добавлены как видимые направления `в разработке`.
227
230
 
228
231
  Инструкция: [Мой домофон](https://github.com/adm-iola/iola-cli/wiki/Мой-домофон).
229
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.2.48",
3
+ "version": "0.2.50",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: ufanet-intercom
3
- description: Мой домофон Уфанет: список домофонов, открытие двери после подтверждения, история звонков, уведомления о новых вызовах, записи звонков, камеры и RTSP.
3
+ description: Мой домофон Уфанет: список домофонов, открытие двери после подтверждения, история звонков, уведомления о новых вызовах, записи звонков, камеры, открытие видео и локальные снимки.
4
4
  ---
5
5
 
6
- Используй этот skill, когда пользователь явно просит работать с домофоном Уфанет: открыть домофон, показать доступные домофоны, посмотреть историю звонков, включить/выключить уведомления о вызовах, получить запись звонка, показать камеры или RTSP.
6
+ Используй этот skill, когда пользователь явно просит работать с домофоном Уфанет: открыть домофон, показать доступные домофоны, посмотреть историю звонков, включить/выключить уведомления о вызовах, получить запись звонка, показать камеры, открыть видео или сохранить снимок с камеры.
7
7
 
8
8
  Не смешивай Уфанет с Яндекс-сервисами, городскими открытыми слоями и локальными файлами. Это отдельный личный сервис пользователя.
9
9
 
@@ -14,7 +14,9 @@ description: Мой домофон Уфанет: список домофонов
14
14
  - `ufanet_open_intercom` - открыть домофон по ID; если ID не указан, CLI сам получает список домофонов.
15
15
  - `ufanet_call_history` - показать историю звонков домофона.
16
16
  - `ufanet_call_links` - получить ссылку на запись/превью звонка по UUID.
17
- - `ufanet_cameras` - показать доступные камеры и RTSP-ссылки.
17
+ - `ufanet_cameras` - показать доступные камеры без вывода RTSP-токенов.
18
+ - `ufanet_camera_open` - открыть видеопоток камеры во внешнем плеере.
19
+ - `ufanet_camera_snapshot` - сохранить один кадр с камеры в локальный файл через ffmpeg.
18
20
 
19
21
  Команды CLI:
20
22
 
@@ -28,6 +30,8 @@ description: Мой домофон Уфанет: список домофонов
28
30
  - `iola ufanet notifications on|off|status` - включить, выключить или проверить настройку уведомлений.
29
31
  - `iola ufanet links UUID` - ссылка на запись звонка.
30
32
  - `iola ufanet cameras` - камеры.
33
+ - `iola ufanet camera open НОМЕР` - открыть видео камеры во внешнем плеере.
34
+ - `iola ufanet camera snapshot НОМЕР` - сохранить снимок камеры в локальный `.jpg`.
31
35
  - `iola ufanet delete` - удалить локальное подключение.
32
36
 
33
37
  Безопасность:
@@ -36,7 +40,8 @@ description: Мой домофон Уфанет: список домофонов
36
40
  - Если пользователь просит "открой домофон", а ID не указан, CLI должен сам получить список домофонов. Если домофон один - открыть его. Если домофонов несколько - показать адреса с цифрами и ждать выбор цифрой; выбранная цифра считается подтверждением.
37
41
  - Если включены уведомления и пришел вызов, в сообщении должен быть вопрос "Открыть?". Ответы `да`, `да открой`, `открой`, `ок` открывают сопоставленный домофон. Ответы `нет`, `отмена` отменяют.
38
42
  - Не открывай домофон по косвенному намерению вроде "кто там", "звонят", "посмотри".
39
- - Не выводи договор, пароль, JWT-токен и другие секреты.
43
+ - Не выводи договор, пароль, JWT-токен, RTSP-ссылки с токенами и другие секреты.
44
+ - В истории звонков не показывай UUID без явной технической необходимости.
40
45
  - Если Уфанет не подключен, скажи запустить `/master` и выбрать `Мой домофон Уфанет`, либо команду `iola ufanet setup`.
41
46
 
42
47
  Дом.ру и Ростелеком:
package/src/cli.js CHANGED
@@ -152,7 +152,7 @@ const LOCAL_TOOLS = ["search_data", "search_entities", "resolve_entity_field", "
152
152
  const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
153
153
  const FILE_TOOLS = ["files_tree", "files_read", "files_search", "files_write", "files_patch"];
154
154
  const USER_SKILL_TOOLS = ["user_skill_create", "user_skill_update", "user_skill_enable", "user_skill_disable", "user_skill_delete", "user_skill_list", "user_skill_templates", "user_skill_validate", "user_skill_preview"];
155
- const UFANET_TOOLS = ["ufanet_status", "ufanet_intercoms", "ufanet_open_intercom", "ufanet_call_history", "ufanet_call_links", "ufanet_cameras"];
155
+ const UFANET_TOOLS = ["ufanet_status", "ufanet_intercoms", "ufanet_open_intercom", "ufanet_call_history", "ufanet_call_links", "ufanet_cameras", "ufanet_camera_open", "ufanet_camera_snapshot"];
156
156
  const YANDEX_TOOLS = [
157
157
  "yandex_identity_me",
158
158
  "yandex_disk_info",
@@ -1282,11 +1282,15 @@ async function startAgentRawInput() {
1282
1282
 
1283
1283
  async function handleAgentLine(line, state) {
1284
1284
  if (!line.startsWith("/")) {
1285
+ if (/^\d{1,2}$/u.test(line.trim()) && (state.pendingAction?.type === "ufanet_menu" || state.lastCommand?.command === "ufanet")) {
1286
+ state.pendingAction = state.pendingAction?.type === "ufanet_menu" ? state.pendingAction : buildUfanetMenuPendingAction();
1287
+ }
1285
1288
  const pendingAnswer = await handlePendingAgentAction(line, state);
1286
1289
  const answer = pendingAnswer || await aiAsk(state.rawMode ? [line, "--quiet"] : [line], { history: state.history, state });
1287
1290
  state.history.push({ role: "user", content: line });
1288
1291
  state.history.push({ role: "assistant", content: answer });
1289
1292
  if (state.rawMode) state.pendingOutput = answer;
1293
+ else if (pendingAnswer) printAiAnswer(pendingAnswer);
1290
1294
  return false;
1291
1295
  }
1292
1296
 
@@ -1308,6 +1312,10 @@ async function handleAgentLine(line, state) {
1308
1312
  }
1309
1313
 
1310
1314
  if (command === "help") {
1315
+ if (state.pendingAction?.type === "ufanet_menu") {
1316
+ await printUfanetMenu({ agentState: state, pending: true });
1317
+ return false;
1318
+ }
1311
1319
  printAgentHelp();
1312
1320
  return false;
1313
1321
  }
@@ -1942,6 +1950,21 @@ async function handlePendingAgentAction(line, state) {
1942
1950
  const result = await ufanetOpenIntercom(action.id, { confirm: true });
1943
1951
  return formatUfanetOpenResult(result, action);
1944
1952
  }
1953
+ if (action.type === "ufanet_menu") {
1954
+ const number = Number(text.match(/^\s*(\d{1,2})\s*$/u)?.[1] || 0);
1955
+ if (number === 0) {
1956
+ state.pendingAction = null;
1957
+ return "Выход из меню домофона.";
1958
+ }
1959
+ if (!number || number < 0 || number > action.items.length) {
1960
+ state.pendingAction = buildUfanetMenuPendingAction();
1961
+ return await formatUfanetMenu({ compact: true });
1962
+ }
1963
+ const item = action.items[number - 1];
1964
+ state.pendingAction = null;
1965
+ const answer = await executeUfanetMenuItem(item, state);
1966
+ return answer || "";
1967
+ }
1945
1968
  return "";
1946
1969
  }
1947
1970
 
@@ -3714,8 +3737,25 @@ async function handleUfanet(args = [], agentState = null) {
3714
3737
  const [action = process.stdin.isTTY ? "menu" : "status", target, ...rest] = args;
3715
3738
  const options = parseOptions(rest);
3716
3739
 
3717
- if (action === "menu" || action === "choose") {
3718
- await printUfanetMenu();
3740
+ if (action === "menu" || action === "choose" || action === "help") {
3741
+ await printUfanetMenu({ agentState, pending: action !== "help" });
3742
+ return;
3743
+ }
3744
+
3745
+ if (/^\d{1,2}$/u.test(String(action))) {
3746
+ const number = Number(action);
3747
+ const items = getUfanetMenuItems();
3748
+ if (number === 0) {
3749
+ console.log("Выход из меню домофона.");
3750
+ return;
3751
+ }
3752
+ const item = items[number - 1];
3753
+ if (!item) {
3754
+ console.log(formatUfanetMenu({ compact: true }));
3755
+ return;
3756
+ }
3757
+ const answer = await executeUfanetMenuItem(item, agentState);
3758
+ if (answer) console.log(answer);
3719
3759
  return;
3720
3760
  }
3721
3761
 
@@ -3752,14 +3792,18 @@ async function handleUfanet(args = [], agentState = null) {
3752
3792
  }
3753
3793
 
3754
3794
  if (action === "history" || action === "calls") {
3755
- const rows = await ufanetGetCallHistory({ page: target || options.page || 1, pageSize: options.limit || options["page-size"] || 10 });
3756
- printTable(rows.results || [], [["uuid", "UUID"], ["calledAt", "Когда"], ["address", "Адрес"], ["porch", "Подъезд"], ["flat", "Кв"]]);
3795
+ const historyOptions = parseOptions([target, ...rest].filter(Boolean));
3796
+ const page = target && !String(target).startsWith("--") ? target : historyOptions.page || 1;
3797
+ const rows = await ufanetGetCallHistory({ page, pageSize: historyOptions.limit || historyOptions["page-size"] || 10 });
3798
+ printTable(formatUfanetCallRows(rows.results || []), [["index", "#"], ["calledAt", "Когда"], ["address", "Адрес"], ["porch", "Подъезд"]]);
3757
3799
  if (rows.count !== undefined) console.log(`Всего: ${rows.count}`);
3758
3800
  return;
3759
3801
  }
3760
3802
 
3761
3803
  if (action === "watch" || action === "listen") {
3762
- await watchUfanetCalls({ ...options, once: options.once, intervalSeconds: options.seconds || options.interval || target });
3804
+ const watchOptions = parseOptions([target, ...rest].filter(Boolean));
3805
+ const intervalTarget = target && !String(target).startsWith("--") ? target : "";
3806
+ await watchUfanetCalls({ ...watchOptions, once: watchOptions.once, intervalSeconds: watchOptions.seconds || watchOptions.interval || intervalTarget });
3763
3807
  return;
3764
3808
  }
3765
3809
 
@@ -3775,9 +3819,30 @@ async function handleUfanet(args = [], agentState = null) {
3775
3819
  return;
3776
3820
  }
3777
3821
 
3778
- if (action === "cameras" || action === "camera") {
3822
+ if (action === "cameras") {
3779
3823
  const rows = await ufanetGetCameras();
3780
- printTable(rows, [["number", "Номер"], ["title", "Название"], ["address", "Адрес"], ["type", "Тип"], ["rtspUrl", "RTSP"]]);
3824
+ console.log(formatUfanetCameraList(rows));
3825
+ return;
3826
+ }
3827
+
3828
+ if (action === "camera") {
3829
+ const subaction = target || "list";
3830
+ const selector = rest[0] || options.number || options.id || options.camera || options._?.[0];
3831
+ if (subaction === "list" || subaction === "ls" || subaction === "cameras") {
3832
+ console.log(formatUfanetCameraList(await ufanetGetCameras()));
3833
+ return;
3834
+ }
3835
+ if (subaction === "open" || subaction === "view" || subaction === "play") {
3836
+ const result = await ufanetOpenCamera(selector || 1);
3837
+ console.log(result.message);
3838
+ return;
3839
+ }
3840
+ if (subaction === "snapshot" || subaction === "screen" || subaction === "screenshot" || subaction === "photo" || subaction === "frame") {
3841
+ const result = await ufanetCameraSnapshot(selector || 1);
3842
+ console.log(`Снимок с камеры сохранен: ${result.file}`);
3843
+ return;
3844
+ }
3845
+ throw new Error("Команды камеры: iola ufanet camera list | open НОМЕР | snapshot НОМЕР");
3781
3846
  return;
3782
3847
  }
3783
3848
 
@@ -3799,33 +3864,94 @@ async function handleUfanet(args = [], agentState = null) {
3799
3864
  iola ufanet history [--limit 10]
3800
3865
  iola ufanet links UUID
3801
3866
  iola ufanet cameras
3867
+ iola ufanet camera open НОМЕР
3868
+ iola ufanet camera snapshot НОМЕР
3802
3869
  iola ufanet watch [--seconds 10]
3803
3870
  iola ufanet notifications on|off|status
3804
3871
  iola ufanet delete`);
3805
3872
  }
3806
3873
 
3807
- async function printUfanetMenu() {
3874
+ async function printUfanetMenu(options = {}) {
3875
+ if (options.agentState && options.pending) {
3876
+ options.agentState.pendingAction = buildUfanetMenuPendingAction();
3877
+ }
3878
+ console.log(await formatUfanetMenu());
3879
+ }
3880
+
3881
+ async function formatUfanetMenu(options = {}) {
3808
3882
  const status = await getUfanetStatus();
3809
- console.log("Мой домофон");
3810
- printTable([
3811
- { id: "ufanet", provider: "Уфанет", status: status.configured ? "готово" : "не настроено", command: "iola ufanet setup" },
3812
- { id: "domru", provider: "Дом.ру", status: "в разработке", command: "iola dom_ru" },
3813
- { id: "rostelecom", provider: "Ростелеком", status: "в разработке", command: "iola rostelecom" },
3814
- ], [["id", "ID"], ["provider", "Провайдер"], ["status", "Статус"], ["command", "Команда"]]);
3815
- console.log("");
3816
- console.log("Команды Уфанет:");
3817
- printTable([
3818
- { command: "/ufanet status", action: "статус подключения" },
3819
- { command: "/ufanet intercoms", action: "список доступных домофонов и их ID" },
3820
- { command: "/ufanet open ID", action: "открыть домофон по ID, только после подтверждения" },
3821
- { command: "/ufanet history", action: "история последних звонков" },
3822
- { command: "/ufanet links UUID", action: "ссылка/превью записи звонка по UUID" },
3823
- { command: "/ufanet cameras", action: "список камер и RTSP-ссылок, если доступны" },
3824
- { command: "/ufanet watch", action: "показывать новые вызовы, пока CLI открыт" },
3825
- { command: "/ufanet notifications on", action: "включить уведомления о вызовах в настройках" },
3826
- { command: "/ufanet notifications off", action: "выключить уведомления о вызовах" },
3827
- { command: "/ufanet delete", action: "удалить локальное подключение Уфанет" },
3828
- ], [["command", "Команда"], ["action", "Что делает"]]);
3883
+ const lines = [
3884
+ "Мой домофон",
3885
+ `Уфанет: ${status.configured ? "готово" : "не настроено"}; уведомления: ${status.notifications}.`,
3886
+ "Дом.ру: в разработке. Ростелеком: в разработке.",
3887
+ "",
3888
+ "Выберите действие:",
3889
+ ...getUfanetMenuItems().map((item) => `${item.number}. ${item.title}`),
3890
+ "0. Назад",
3891
+ "",
3892
+ "Введите цифру или команду вида `/ufanet 3`. Пока открыт этот раздел, `/help` показывает справку по домофону.",
3893
+ ];
3894
+ return options.compact ? lines.slice(4).join("\n") : lines.join("\n");
3895
+ }
3896
+
3897
+ function getUfanetMenuItems() {
3898
+ return [
3899
+ { number: 1, key: "open", title: "Открыть домофон" },
3900
+ { number: 2, key: "intercoms", title: "Показать мои домофоны" },
3901
+ { number: 3, key: "history", title: "История звонков" },
3902
+ { number: 4, key: "notifications-on", title: "Включить уведомления о вызовах" },
3903
+ { number: 5, key: "notifications-off", title: "Выключить уведомления" },
3904
+ { number: 6, key: "notifications-status", title: "Статус уведомлений" },
3905
+ { number: 7, key: "cameras", title: "Камеры" },
3906
+ { number: 8, key: "status", title: "Статус подключения" },
3907
+ { number: 9, key: "delete", title: "Удалить подключение Уфанет" },
3908
+ ];
3909
+ }
3910
+
3911
+ function buildUfanetMenuPendingAction() {
3912
+ return { type: "ufanet_menu", items: getUfanetMenuItems(), createdAt: new Date().toISOString() };
3913
+ }
3914
+
3915
+ async function executeUfanetMenuItem(item, agentState = null) {
3916
+ if (!item) return "";
3917
+ if (item.key === "open") {
3918
+ const result = await ufanetOpenSmart({ state: agentState });
3919
+ return formatUfanetSmartOpenResult(result);
3920
+ }
3921
+ if (item.key === "intercoms") {
3922
+ const rows = await ufanetGetIntercoms();
3923
+ if (!rows.length) return "Доступные домофоны Уфанет не найдены.";
3924
+ return ["Домофоны Уфанет:", ...rows.map((row, index) => `${index + 1}. ${row.address || row.name || `Домофон #${row.id}`} — ID ${row.id}`)].join("\n");
3925
+ }
3926
+ if (item.key === "history") {
3927
+ const rows = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
3928
+ if (!rows.results?.length) return "В истории Уфанет звонков не найдено.";
3929
+ return formatUfanetCallHistory(rows.results);
3930
+ }
3931
+ if (item.key === "notifications-on") {
3932
+ await setUfanetNotifications(true);
3933
+ return "Уведомления Уфанет включены. При новом вызове в CLI можно ответить `да` или `нет`.";
3934
+ }
3935
+ if (item.key === "notifications-off") {
3936
+ await setUfanetNotifications(false);
3937
+ return "Уведомления Уфанет выключены.";
3938
+ }
3939
+ if (item.key === "notifications-status") {
3940
+ const status = await getUfanetStatus();
3941
+ return `Уведомления Уфанет: ${status.notifications}, интервал ${status.intervalSeconds} сек.`;
3942
+ }
3943
+ if (item.key === "cameras") {
3944
+ const rows = await ufanetGetCameras();
3945
+ return formatUfanetCameraList(rows);
3946
+ }
3947
+ if (item.key === "status") {
3948
+ const status = await getUfanetStatus();
3949
+ return `Уфанет: ${status.configured ? "настроен" : "не настроен"}, ${status.enabled ? "включен" : "выключен"}, уведомления ${status.notifications}.`;
3950
+ }
3951
+ if (item.key === "delete") {
3952
+ return "Удаление подключения выполняется явной командой: `/ufanet delete`.";
3953
+ }
3954
+ return "";
3829
3955
  }
3830
3956
 
3831
3957
  async function setupUfanetConnector() {
@@ -4013,7 +4139,7 @@ async function watchUfanetCalls(options = {}) {
4013
4139
  await updateUfanetLastSeen(lastSeen);
4014
4140
  }
4015
4141
  if (options.once) {
4016
- printTable(initialRows, [["uuid", "UUID"], ["calledAt", "Когда"], ["address", "Адрес"], ["porch", "Подъезд"], ["flat", "Кв"]]);
4142
+ printTable(formatUfanetCallRows(initialRows), [["index", "#"], ["calledAt", "Когда"], ["address", "Адрес"], ["porch", "Подъезд"]]);
4017
4143
  return;
4018
4144
  }
4019
4145
  console.log(`Уфанет: слежу за новыми вызовами каждые ${intervalSeconds} сек. Остановить: Ctrl+C.`);
@@ -4035,11 +4161,9 @@ async function watchUfanetCalls(options = {}) {
4035
4161
  console.log("");
4036
4162
  console.log("Новый вызов домофона Уфанет:");
4037
4163
  printKeyValue({
4038
- uuid: row.uuid || "-",
4039
- calledAt: row.calledAt || "-",
4164
+ calledAt: formatUfanetCallDate(row.calledAt),
4040
4165
  address: row.address || "-",
4041
4166
  porch: row.porch || "-",
4042
- flat: row.flat || "-",
4043
4167
  });
4044
4168
  }
4045
4169
  lastSeen = ufanetCallKey(rows[0]) || lastSeen;
@@ -4066,6 +4190,8 @@ async function executeUfanetTool(tool, args = {}) {
4066
4190
  if (tool === "ufanet_call_history") return ufanetGetCallHistory({ page: args.page || 1, pageSize: args.pageSize || args.page_size || args.limit || 10 });
4067
4191
  if (tool === "ufanet_call_links") return ufanetGetCallLinks(args.uuid || args.id);
4068
4192
  if (tool === "ufanet_cameras") return ufanetGetCameras();
4193
+ if (tool === "ufanet_camera_open") return ufanetOpenCamera(args.number || args.id || args.camera || 1);
4194
+ if (tool === "ufanet_camera_snapshot") return ufanetCameraSnapshot(args.number || args.id || args.camera || 1);
4069
4195
  throw new Error(`Ufanet tool неизвестен: ${tool}`);
4070
4196
  }
4071
4197
 
@@ -4249,6 +4375,126 @@ async function ufanetGetCameras() {
4249
4375
  }));
4250
4376
  }
4251
4377
 
4378
+ function formatUfanetCameraList(rows = []) {
4379
+ if (!rows.length) return "Камеры Уфанет не найдены.";
4380
+ const lines = [
4381
+ "Камеры Уфанет:",
4382
+ ...rows.slice(0, 20).map((row, index) => {
4383
+ const title = formatUfanetCameraTitle(row);
4384
+ const video = row.rtspUrl ? "видео доступно" : "видео недоступно";
4385
+ return `${index + 1}. ${title} — ${row.address || "-"} (${video})`;
4386
+ }),
4387
+ "",
4388
+ "Что можно сделать:",
4389
+ "- открыть видео: /ufanet camera open НОМЕР",
4390
+ "- сохранить снимок: /ufanet camera snapshot НОМЕР",
4391
+ "",
4392
+ "RTSP-ссылки с токенами не выводятся в консоль. Для просмотра нужен установленный видеоплеер, например VLC; для снимка нужен ffmpeg.",
4393
+ ];
4394
+ return lines.join("\n");
4395
+ }
4396
+
4397
+ function formatUfanetCameraTitle(row = {}) {
4398
+ const title = String(row.title || "").trim().replace(/^\d+\s*,\s*/u, "");
4399
+ return title || "Камера";
4400
+ }
4401
+
4402
+ function formatUfanetCallRows(rows = []) {
4403
+ return rows.map((row, index) => ({
4404
+ index: index + 1,
4405
+ calledAt: formatUfanetCallDate(row.calledAt),
4406
+ address: row.address || "-",
4407
+ porch: row.porch || "-",
4408
+ }));
4409
+ }
4410
+
4411
+ function formatUfanetCallHistory(rows = []) {
4412
+ if (!rows.length) return "В истории Уфанет звонков не найдено.";
4413
+ return [
4414
+ "История звонков Уфанет:",
4415
+ ...formatUfanetCallRows(rows).map((row) => `${row.index}. ${row.calledAt} — ${row.address}${row.porch && row.porch !== "-" ? `, подъезд ${row.porch}` : ""}`),
4416
+ "",
4417
+ "Служебные UUID скрыты. Если нужна запись конкретного звонка, запросите ее по номеру из истории.",
4418
+ ].join("\n");
4419
+ }
4420
+
4421
+ function formatUfanetCallDate(value) {
4422
+ if (!value) return "-";
4423
+ const date = new Date(value);
4424
+ if (!Number.isNaN(date.getTime())) {
4425
+ return new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "short" }).format(date);
4426
+ }
4427
+ return String(value);
4428
+ }
4429
+
4430
+ function safeUfanetCamera(row = {}) {
4431
+ return {
4432
+ provider: "ufanet",
4433
+ title: row.title || "",
4434
+ address: row.address || "",
4435
+ type: row.type || "",
4436
+ hasVideo: Boolean(row.rtspUrl),
4437
+ };
4438
+ }
4439
+
4440
+ function resolveUfanetCamera(rows = [], selector = 1) {
4441
+ if (!rows.length) throw new Error("Камеры Уфанет не найдены.");
4442
+ const raw = String(selector || "1").trim();
4443
+ const index = Number(raw);
4444
+ if (Number.isInteger(index) && index >= 1 && index <= rows.length) return rows[index - 1];
4445
+ const normalized = normalizeGeoText(raw);
4446
+ const match = rows.find((row) => String(row.number || "") === raw)
4447
+ || rows.find((row) => normalizeGeoText(`${row.title || ""} ${row.address || ""}`).includes(normalized));
4448
+ if (match) return match;
4449
+ throw new Error(`Не нашел камеру: ${raw}. Сначала посмотрите список: /ufanet cameras`);
4450
+ }
4451
+
4452
+ async function ufanetOpenCamera(selector = 1) {
4453
+ const rows = await ufanetGetCameras();
4454
+ const camera = resolveUfanetCamera(rows, selector);
4455
+ if (!camera.rtspUrl) throw new Error("У этой камеры нет доступного видеопотока.");
4456
+ await openUrl(camera.rtspUrl);
4457
+ return {
4458
+ ...safeUfanetCamera(camera),
4459
+ status: "camera-opened",
4460
+ message: "Открываю видеопоток во внешнем плеере. Если окно не появилось, установите VLC и назначьте его для RTSP-ссылок.",
4461
+ };
4462
+ }
4463
+
4464
+ async function ufanetCameraSnapshot(selector = 1) {
4465
+ const rows = await ufanetGetCameras();
4466
+ const camera = resolveUfanetCamera(rows, selector);
4467
+ if (!camera.rtspUrl) throw new Error("У этой камеры нет доступного видеопотока для снимка.");
4468
+ const outputDir = path.join(CONFIG_DIR, "artifacts", "ufanet");
4469
+ await mkdir(outputDir, { recursive: true });
4470
+ const fileName = `${slugForFile(formatUfanetCameraTitle(camera))}-${timestampForFile()}.jpg`;
4471
+ const outputFile = path.join(outputDir, fileName);
4472
+ try {
4473
+ await runCommand("ffmpeg", [
4474
+ "-y",
4475
+ "-rtsp_transport",
4476
+ "tcp",
4477
+ "-i",
4478
+ camera.rtspUrl,
4479
+ "-frames:v",
4480
+ "1",
4481
+ outputFile,
4482
+ ], { timeoutMs: 20000 });
4483
+ } catch (error) {
4484
+ const message = error instanceof Error ? error.message : String(error);
4485
+ if (/ENOENT|not recognized|не является|cannot find|spawn/i.test(message)) {
4486
+ throw new Error("Для снимков с камеры нужен ffmpeg. Установите ffmpeg и повторите: /ufanet camera snapshot НОМЕР");
4487
+ }
4488
+ throw new Error(`Не смог сохранить снимок с камеры: ${message}`);
4489
+ }
4490
+ saveArtifact("ufanet-camera-snapshot", formatUfanetCameraTitle(camera), outputFile, safeUfanetCamera(camera));
4491
+ return {
4492
+ ...safeUfanetCamera(camera),
4493
+ status: "camera-snapshot-saved",
4494
+ file: outputFile,
4495
+ };
4496
+ }
4497
+
4252
4498
  function printYandexServices(options = {}) {
4253
4499
  const rows = Object.entries(YANDEX_CONNECTOR_SERVICES)
4254
4500
  .filter(([, service]) => !options.status || service.status === options.status)
@@ -14799,12 +15045,19 @@ async function buildUfanetDirectAnswer(question, context = {}) {
14799
15045
  if (/(истори|звонк|кто звонил|последн)/iu.test(normalized)) {
14800
15046
  const rows = await ufanetGetCallHistory({ page: 1, pageSize: 10 });
14801
15047
  if (!rows.results?.length) return "В истории Уфанет звонков не найдено.";
14802
- return ["История звонков Уфанет:", ...rows.results.map((row, index) => `${index + 1}. ${row.calledAt || "-"} — ${row.address || "-"}${row.uuid ? `, UUID ${row.uuid}` : ""}`)].join("\n");
15048
+ return formatUfanetCallHistory(rows.results);
15049
+ }
15050
+ if (/(сним|скриншот|фото|кадр)/iu.test(normalized) && /(камер|видео|домофон)/iu.test(normalized)) {
15051
+ const result = await ufanetCameraSnapshot(extractUfanetCameraSelector(question) || 1);
15052
+ return `Снимок с камеры сохранен: ${result.file}`;
15053
+ }
15054
+ if (/(открой|покажи|запусти|включи)/iu.test(normalized) && /(камер|видео)/iu.test(normalized)) {
15055
+ const result = await ufanetOpenCamera(extractUfanetCameraSelector(question) || 1);
15056
+ return result.message;
14803
15057
  }
14804
15058
  if (/(камер|rtsp|видео)/iu.test(normalized)) {
14805
15059
  const rows = await ufanetGetCameras();
14806
- if (!rows.length) return "Камеры Уфанет не найдены.";
14807
- return ["Камеры Уфанет:", ...rows.slice(0, 10).map((row, index) => `${index + 1}. ${row.title || row.number || "камера"} — ${row.address || "-"}${row.rtspUrl ? `, ${row.rtspUrl}` : ""}`)].join("\n");
15060
+ return formatUfanetCameraList(rows);
14808
15061
  }
14809
15062
  const intercoms = await ufanetGetIntercoms();
14810
15063
  if (!intercoms.length) return "Доступные домофоны Уфанет не найдены.";
@@ -14816,6 +15069,12 @@ function extractSecondsFromText(text) {
14816
15069
  return match ? Number(match[1]) : 0;
14817
15070
  }
14818
15071
 
15072
+ function extractUfanetCameraSelector(text) {
15073
+ return String(text || "").match(/(?:камер[ауые]?|номер|№|#)\s*(\d{1,4})/iu)?.[1]
15074
+ || String(text || "").match(/\b(\d{1,4})\b/u)?.[1]
15075
+ || "";
15076
+ }
15077
+
14819
15078
  function detectDirectDataFields(normalizedQuestion) {
14820
15079
  const fields = [];
14821
15080
  if (/(директ|руководител|заведующ|кто возглавляет)/iu.test(normalizedQuestion)) fields.push("head");
@@ -15328,8 +15587,8 @@ async function buildLocalToolPlan(question, providerConfig, options) {
15328
15587
  "Схема: {\"steps\":[{\"tool\":\"search_data\",\"args\":{\"dataset\":\"schools|kindergartens|all\",\"query\":\"text\",\"limit\":10}}]}",
15329
15588
  "Минимальные tools: search_data {dataset,query,limit}, get_card {query}, export_report {name,format,output}, file_read {path}, browser_open {url}.",
15330
15589
  "Yandex tools: yandex_identity_me {}, yandex_disk_info {}, yandex_disk_ls {path}, yandex_disk_mkdir {path}, yandex_disk_find {query,path}, yandex_disk_stat {path}, yandex_disk_exists {path}, yandex_disk_read_text {path}, yandex_disk_save_text {path,text}, yandex_disk_upload {localPath,remotePath}, yandex_disk_download {remotePath,outputPath}, yandex_disk_move {from,to,confirm}, yandex_disk_copy {from,to,confirm}, yandex_disk_rename {path,name,confirm}, yandex_disk_share {path,confirm}, yandex_disk_share_qr {path,confirm}, yandex_disk_share_email {path,to,contact,subject,text,confirm}, yandex_disk_package_share_email {sourcePath,targetFolder,to,contact,mode,confirm}, yandex_disk_unshare {path}, yandex_disk_delete {path,confirm}, yandex_disk_trash_list {}, yandex_disk_restore {path,confirm}, yandex_disk_empty_trash {confirm}, yandex_mail_folders {}, yandex_mail_list {mailbox,limit,unread}, yandex_mail_search {mailbox,query}, yandex_mail_read {mailbox,uid}, yandex_mail_mark {mailbox,uid,seen}, yandex_mail_send {to,subject,text,confirm}, yandex_mail_reply {uid,text,confirm}, yandex_mail_forward {uid,to,confirm}, yandex_mail_save_to_disk {uid,path}, yandex_mail_city_context {uid}, yandex_mail_map_addresses {uid}, yandex_mail_create_task {uid,title}, yandex_mail_meeting_pack {uid,start,end,send,confirm}, yandex_calendar_calendars {}, yandex_calendar_list {start,end}, yandex_calendar_search {query,start,end}, yandex_calendar_get {query}, yandex_calendar_create_event {title,start,end,location,attendees,reminders,confirm}, yandex_calendar_update {query,title,start,end,location,description,reminders,confirm}, yandex_calendar_move {query,start,end,confirm}, yandex_calendar_delete {query,confirm}, yandex_docs_list {path}, yandex_docs_find {query}, yandex_docs_create_text {title,text,format,confirm}, yandex_docs_read {path|query}, yandex_docs_share {path|query,confirm}, yandex_docs_rename {path|query,name,confirm}, yandex_docs_delete {path|query,confirm}, yandex_contacts_list {limit}, yandex_contacts_search {query}, yandex_contacts_get {query}, yandex_contacts_create {name,email,phone,address,note,confirm}, yandex_contacts_update {query,email,phone,address,note,birthday,org,title,confirm}, yandex_contacts_delete {query,confirm}, yandex_contacts_export_csv {}, yandex_contacts_find_incomplete {}, yandex_contacts_find_duplicates {}, yandex_contacts_backup_to_disk {format,confirm}, yandex_contact_send_mail {contact,subject,text,confirm}, yandex_contact_send_disk_link_qr {contact,path,confirm}, yandex_contact_create_disk_folder {contact,confirm}, yandex_contact_create_calendar_event {contact,start,end,title,confirm}, yandex_contact_create_telemost_event {contact,start,end,title,confirm}, yandex_contact_full_pack {contact,start,end,send,confirm}, yandex_cloud_status {}, yandex_go_deeplink {from,to,tariff}, yandex_daily_digest {save,email}, yandex_calendar_reminders_tick {}, yandex_disk_maintenance_tick {}.",
15331
- "Ufanet tools: ufanet_status {}, ufanet_intercoms {}, ufanet_open_intercom {id,confirm}, ufanet_call_history {page,limit}, ufanet_call_links {uuid}, ufanet_cameras {}.",
15332
- "Опасные Yandex/Ufanet tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_share_qr, yandex_disk_share_email, yandex_disk_package_share_email, yandex_disk_delete, yandex_disk_move, yandex_disk_copy, yandex_disk_rename, yandex_disk_restore, yandex_disk_empty_trash, yandex_mail_send, yandex_mail_reply, yandex_mail_forward, yandex_mail_delete, yandex_mail_create_calendar_event, yandex_mail_sender_to_contact, yandex_mail_meeting_pack, yandex_contacts_create, yandex_contacts_update, yandex_contacts_delete, yandex_contacts_add_email, yandex_contacts_add_phone, yandex_contacts_add_address, yandex_contacts_backup_to_disk, yandex_contact_send_mail, yandex_contact_send_disk_link_qr, yandex_contact_create_disk_folder, yandex_contact_create_calendar_event, yandex_contact_create_telemost_event, yandex_contact_full_pack, yandex_calendar_create_event, yandex_calendar_update, yandex_calendar_move, yandex_calendar_delete, yandex_calendar_add_reminder, yandex_docs_create_text, yandex_docs_share, yandex_docs_rename, yandex_docs_delete, yandex_telemost_create_event, ufanet_open_intercom.",
15590
+ "Ufanet tools: ufanet_status {}, ufanet_intercoms {}, ufanet_open_intercom {id,confirm}, ufanet_call_history {page,limit}, ufanet_call_links {uuid}, ufanet_cameras {}, ufanet_camera_open {number}, ufanet_camera_snapshot {number}.",
15591
+ "Опасные Yandex/Ufanet tools используй только при явной просьбе пользователя и с confirm=true: yandex_disk_share, yandex_disk_share_qr, yandex_disk_share_email, yandex_disk_package_share_email, yandex_disk_delete, yandex_disk_move, yandex_disk_copy, yandex_disk_rename, yandex_disk_restore, yandex_disk_empty_trash, yandex_mail_send, yandex_mail_reply, yandex_mail_forward, yandex_mail_delete, yandex_mail_create_calendar_event, yandex_mail_sender_to_contact, yandex_mail_meeting_pack, yandex_contacts_create, yandex_contacts_update, yandex_contacts_delete, yandex_contacts_add_email, yandex_contacts_add_phone, yandex_contacts_add_address, yandex_contacts_backup_to_disk, yandex_contact_send_mail, yandex_contact_send_disk_link_qr, yandex_contact_create_disk_folder, yandex_contact_create_calendar_event, yandex_contact_create_telemost_event, yandex_contact_full_pack, yandex_calendar_create_event, yandex_calendar_update, yandex_calendar_move, yandex_calendar_delete, yandex_calendar_add_reminder, yandex_docs_create_text, yandex_docs_share, yandex_docs_rename, yandex_docs_delete, yandex_telemost_create_event, ufanet_open_intercom, ufanet_camera_open, ufanet_camera_snapshot.",
15333
15592
  "User skill tools: user_skill_create {name,description,instructions,tools,template,enable,confirm}, user_skill_update {name,instructions,tools,confirm}, user_skill_templates {}, user_skill_validate {name}, user_skill_preview {name,template,instructions}, user_skill_enable {name}, user_skill_disable {name}, user_skill_delete {name,confirm}, user_skill_list {}. Создавай или меняй skill только по явной просьбе пользователя и с confirm=true.",
15334
15593
  "MCP tools доступны как mcp:SERVER:TOOL, например mcp:iola-local:search.",
15335
15594
  "Для выгрузки CSV добавь export_report с format=csv и output, если пользователь назвал файл.",
@@ -15439,12 +15698,18 @@ function inferToolPlan(question, options = {}) {
15439
15698
  if (/(истори|звонк|кто\s+звонил|последн)/iu.test(normalized) && !/(ссылк|запис|видео)/iu.test(normalized)) {
15440
15699
  return { steps: [{ tool: "ufanet_call_history", args: { limit: 10 } }] };
15441
15700
  }
15442
- if (/(ссылк|запис|видео|preview|превью)/iu.test(normalized)) {
15701
+ if (/(сним|скриншот|фото|кадр)/iu.test(normalized) && /(камер|видео|домофон)/iu.test(normalized)) {
15702
+ return { steps: [{ tool: "ufanet_camera_snapshot", args: { number: extractUfanetCameraSelector(question) || 1 } }] };
15703
+ }
15704
+ if (/(открой|покажи|запусти|включи)/iu.test(normalized) && /(камер|видео)/iu.test(normalized)) {
15705
+ return { steps: [{ tool: "ufanet_camera_open", args: { number: extractUfanetCameraSelector(question) || 1 } }] };
15706
+ }
15707
+ if (/(камер|rtsp|видео)/iu.test(normalized)) return { steps: [{ tool: "ufanet_cameras", args: {} }] };
15708
+ if (/(ссылк|запис|preview|превью)/iu.test(normalized)) {
15443
15709
  const uuid = extractUuid(question);
15444
15710
  if (!uuid) return { directAnswer: "Для ссылки на запись нужен UUID звонка. Сначала посмотрите историю: /ufanet history." };
15445
15711
  return { steps: [{ tool: "ufanet_call_links", args: { uuid } }] };
15446
15712
  }
15447
- if (/(камер|rtsp|видео)/iu.test(normalized)) return { steps: [{ tool: "ufanet_cameras", args: {} }] };
15448
15713
  if (/(статус|подключ|аккаунт|договор)/iu.test(normalized)) return { steps: [{ tool: "ufanet_status", args: {} }] };
15449
15714
  return { steps: [{ tool: "ufanet_intercoms", args: {} }] };
15450
15715
  }
@@ -16103,9 +16368,11 @@ function formatToolResult(result, options) {
16103
16368
  if (row.type === "empty") return "Уфанет подключен, но доступных домофонов не найдено.";
16104
16369
  if (row.type === "opened" && row.result) return formatUfanetOpenResult(row.result, row.choice || {});
16105
16370
  if (row.provider === "ufanet" && (row.status === "opened" || row.status === "not-opened")) return `Уфанет: домофон #${row.id} ${row.status === "opened" ? "открыт" : "не открылся"}.`;
16371
+ if (row.provider === "ufanet" && row.status === "camera-opened") return row.message || `Открываю камеру Уфанет: ${row.title || row.number || row.address || "-"}.`;
16372
+ if (row.provider === "ufanet" && row.status === "camera-snapshot-saved") return `Снимок с камеры сохранен: ${row.file}`;
16106
16373
  if (row.provider === "ufanet" && row.uuid && (row.url || row.preview)) return `Уфанет: запись звонка ${row.uuid}\nСсылка: ${row.url || "-"}\nПревью: ${row.preview || "-"}`;
16107
- if (row.rtspUrl) return `Камера Уфанет ${row.title || row.number}: ${row.address || "-"}\nRTSP: ${row.rtspUrl}`;
16108
- if (row.calledAt && row.uuid) return `Звонок Уфанет: ${row.calledAt}, ${row.address || "-"}, подъезд ${row.porch || "-"}, UUID ${row.uuid}`;
16374
+ if (row.rtspUrl || row.hasVideo !== undefined) return `Камера Уфанет ${row.title || row.number || "-"}: ${row.address || "-"}${row.rtspUrl || row.hasVideo ? "\nВидео доступно. Открыть: /ufanet camera open НОМЕР. Снимок: /ufanet camera snapshot НОМЕР." : ""}`;
16375
+ if (row.calledAt && row.uuid) return `Звонок Уфанет: ${formatUfanetCallDate(row.calledAt)}, ${row.address || "-"}, подъезд ${row.porch || "-"}`;
16109
16376
  if (row.id && (row.address || row.role || row.blocked !== undefined)) return `Домофон Уфанет #${row.id}: ${row.name || row.address || "-"}${row.blocked === "yes" ? " (заблокирован)" : ""}`;
16110
16377
  if (row.configured !== undefined && row.enabled !== undefined && row.contract !== undefined) return `Уфанет: ${row.configured ? "настроен" : "не настроен"}, ${row.enabled ? "включен" : "выключен"}${row.contract ? `, договор ${row.contract}` : ""}.`;
16111
16378
  if (row.status === "calendar-event-created" || row.status === "telemost-event-created" || row.status === "telemost-calendar-fallback-created") {
@@ -20248,6 +20515,7 @@ function runCommand(command, args, options = {}) {
20248
20515
  ...(options.env || {}),
20249
20516
  },
20250
20517
  }, (error, stdout, stderr) => {
20518
+ if (timer) clearTimeout(timer);
20251
20519
  if (error) {
20252
20520
  if (process.platform === "win32" && (error.code === "ENOENT" || error.code === "EINVAL") && !options.cmdFallback) {
20253
20521
  runCommand(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c", quoteWindowsCommand(command, args)], {
@@ -20263,6 +20531,10 @@ function runCommand(command, args, options = {}) {
20263
20531
 
20264
20532
  resolve({ stdout, stderr });
20265
20533
  });
20534
+ const timer = options.timeoutMs ? setTimeout(() => {
20535
+ child.kill();
20536
+ reject(new Error(`Command timeout after ${options.timeoutMs} ms: ${command}`));
20537
+ }, Number(options.timeoutMs)) : null;
20266
20538
 
20267
20539
  if (options.inherit) {
20268
20540
  child.stdout?.pipe(process.stdout);
@@ -101,8 +101,10 @@ Toolset `ufanet` включает tools для личного домофона
101
101
  - `ufanet_open_intercom` - открыть домофон по ID;
102
102
  - `ufanet_call_history` - история звонков;
103
103
  - `ufanet_call_links` - ссылка на запись звонка по UUID;
104
- - `ufanet_cameras` - камеры и RTSP-ссылки.
104
+ - `ufanet_cameras` - безопасный список камер без вывода RTSP-токенов;
105
+ - `ufanet_camera_open` - открыть видеопоток камеры во внешнем плеере;
106
+ - `ufanet_camera_snapshot` - сохранить локальный снимок камеры через ffmpeg.
105
107
 
106
108
  Уведомления о новых вызовах доступны командой `iola ufanet watch`; включение/выключение настройки - `iola ufanet notifications on|off`.
107
109
 
108
- Открытие домофона требует явного подтверждения `confirm=true`. Договор, пароль и JWT хранятся локально и не выводятся в ответах.
110
+ Открытие домофона требует явного подтверждения `confirm=true`. Договор, пароль, JWT и RTSP-ссылки с токенами хранятся локально и не выводятся в ответах.
@@ -91,6 +91,7 @@ iola yandex token delete
91
91
  Мой домофон:
92
92
 
93
93
  ```bash
94
+ iola ufanet
94
95
  iola ufanet setup
95
96
  iola ufanet status
96
97
  iola ufanet intercoms
@@ -99,6 +100,8 @@ iola ufanet open ID
99
100
  iola ufanet history --limit 10
100
101
  iola ufanet links UUID
101
102
  iola ufanet cameras
103
+ iola ufanet camera open НОМЕР
104
+ iola ufanet camera snapshot НОМЕР
102
105
  iola ufanet watch --seconds 10
103
106
  iola ufanet notifications on
104
107
  iola ufanet notifications off
@@ -7,11 +7,14 @@
7
7
  Рабочий провайдер:
8
8
 
9
9
  ```bash
10
+ iola ufanet
10
11
  iola ufanet setup
11
12
  iola ufanet status
12
13
  iola ufanet intercoms
13
14
  iola ufanet history
14
15
  iola ufanet cameras
16
+ iola ufanet camera open НОМЕР
17
+ iola ufanet camera snapshot НОМЕР
15
18
  iola ufanet links UUID
16
19
  iola ufanet open
17
20
  iola ufanet open ID
@@ -22,6 +25,8 @@ iola ufanet notifications status
22
25
  iola ufanet delete
23
26
  ```
24
27
 
28
+ В интерактивном CLI команда `/ufanet` открывает отдельное меню домофона. В нем можно выбрать действие цифрой: открыть домофон, посмотреть список, историю, уведомления, камеры или статус.
29
+
25
30
  При настройке нужны:
26
31
 
27
32
  - номер договора Уфанет;
@@ -48,12 +53,33 @@ UFANET_PASSWORD
48
53
  - открыть домофон по ID;
49
54
  - показать историю звонков;
50
55
  - получить ссылку на запись звонка по UUID;
51
- - показать камеры;
52
- - показать RTSP-ссылки камер;
56
+ - показать камеры без вывода технических RTSP-токенов;
57
+ - открыть видеопоток камеры во внешнем плеере;
58
+ - сохранить локальный снимок с камеры;
53
59
  - получать уведомления о новых вызовах, пока CLI открыт;
54
60
  - включить или выключить настройку уведомлений;
55
61
  - удалить локальное подключение.
56
62
 
63
+ Камеры показываются как обычный список: номер, название, адрес и признак доступности видео. Сами RTSP-ссылки с токенами в консоль не выводятся.
64
+
65
+ Открыть видео:
66
+
67
+ ```bash
68
+ iola ufanet camera open 1
69
+ ```
70
+
71
+ CLI передает поток во внешний видеоплеер. На Windows удобнее всего поставить VLC и назначить его для RTSP-ссылок.
72
+
73
+ Сохранить один кадр в локальный файл:
74
+
75
+ ```bash
76
+ iola ufanet camera snapshot 1
77
+ ```
78
+
79
+ Для снимка нужен `ffmpeg`. Файл сохраняется в `~/.iola/artifacts/ufanet/` и регистрируется в artifacts CLI.
80
+
81
+ История звонков в обычном выводе показывает время, адрес и подъезд. Служебные UUID скрываются, чтобы не перегружать пользователя техническими идентификаторами.
82
+
57
83
  Открытие двери всегда требует явного действия пользователя. Если домофон один, фраза `открой домофон` считается таким действием. Если домофонов несколько, CLI выводит адреса с цифрами, а выбранная цифра считается подтверждением.
58
84
 
59
85
  Уведомления сейчас работают через периодическую проверку истории звонков Уфанета:
@@ -416,7 +416,9 @@ iola yandex go open --from "Йошкар-Ола, Красноармейская
416
416
  - открыть выбранный домофон по ID;
417
417
  - показать историю звонков;
418
418
  - получить ссылку на запись звонка по UUID;
419
- - показать камеры и RTSP-ссылки;
419
+ - показать камеры без вывода RTSP-токенов;
420
+ - открыть видео камеры во внешнем плеере;
421
+ - сохранить локальный снимок камеры;
420
422
  - удалить локальное подключение.
421
423
 
422
424
  Открытие двери всегда требует явного подтверждения пользователя.
@@ -429,6 +431,8 @@ iola ufanet intercoms
429
431
  iola ufanet open ID
430
432
  iola ufanet history
431
433
  iola ufanet cameras
434
+ iola ufanet camera open 1
435
+ iola ufanet camera snapshot 1
432
436
  iola ufanet watch
433
437
  iola ufanet notifications on
434
438
  iola ufanet notifications off