Интеграция с внешними сервисами, визуализация и тестирование
COURSE-16-M07-LAB03 — Интеграция с внешними сервисами, визуализация и тестирование
Введение
На предыдущих этапах вы освоили базовые операции с контроллером HI, научились считывать данные с датчиков и управлять реле. Данная лабораторная работа является завершающей в модуле и объединяет полученные навыки для создания комплексного решения. Вы реализуете сценарий автоматического климат-контроля, который будет учитывать не только внутренние показания, но и данные из внешнего мира, полученные через API.
Вы научитесь интегрировать сторонние веб-сервисы, строить сложную логику на базе конечных автоматов (FSM), создавать простой интерфейс для мониторинга и разрабатывать план тестирования для проверки надежности вашей системы.
Цели лабораторной работы
- Реализовать сложный сценарий автоматизации с использованием паттерна "Конечный автомат" (FSM) в Node-RED.
- Интегрировать контроллер с внешним API для получения данных (на примере погодного сервиса).
- Создать панель мониторинга (Dashboard) для визуализации данных и состояния системы в реальном времени.
- Разработать и выполнить тест-план для проверки функциональности, надежности и обработки ошибок системы.
Сценарий: Интеллектуальный климат-контроль в офисном помещении
Задача: Автоматизировать работу кондиционера в офисном помещении на базе контроллера HI. Логика работы системы:* Температура в помещении превышает 25°C.
* И при этом прогнозируемая температура на улице выше 22°C (чтобы не включать кондиционер без надобности, если на улице прохладно).
Шаг 1: Реализация логики климат-контроля (Конечный автомат)
Для управления сложным поведением, зависящим от множества условий и предыдущего состояния, мы применим паттерн "Конечный автомат" (Finite State Machine, FSM).
Состояния автомата:- `IDLE`: Режим ожидания. Кондиционер выключен.
- `COOLING`: Режим охлаждения. Кондиционер включен.
- `IDLE` -> `COOLING`: Если `temp_in > 25` И `temp_out > 22`.
- `COOLING` -> `IDLE`: Если `temp_in < 23`.
contextStorage: {
default: {
module: "localfilesystem"
},
// Для персистентного хранения в MySQL (если настроен)
// mysql: {
// module: "node-red-contrib-mysqldb-context",
// host: "localhost",
// port: 3306,
// user: "nodered",
// password: "your_password",
// database: "nodered_context"
// }
},
💡 Совет: Для критически важных систем рекомендуется использовать MySQL для хранения контекста, так как это обеспечивает более высокую надежность и возможность резервного копирования.
ASCII-схема потока:// Поток данных от внутреннего датчика
[Inject: 10s] -> [1-Wire In: DS18B20] -> [Function: "Prepare Internal Temp"] -> [Link Out: "To FSM Input"]
// Поток данных от внешнего API
[Inject: 15m] -> [HTTP Request: Weather API] -> [Function: "Prepare External Temp"] -> [Link Out: "To FSM Input"]
// Основной поток логики FSM
[Link In: "To FSM Input"] -> [Function: "FSM Logic"] --+-- (сообщение для реле) --> [Relay Out: AC Control]
|
+-- (сообщение для dashboard) --> [Link Out: "To Dashboard"]
|
+-- (сообщение для лога) --> [Link Out: "To Audit Log"]
// Поток обработки ошибок на вкладке "Климат-контроль"
[Catch: All Nodes] -> [Function: "Format Error"] -> [Link Out: "To Audit Log"]
// Отдельный поток для аудита (может быть на другой вкладке)
[Link In: "To Audit Log"] -> [Function: "Format Audit Message"] -> [MySQL Out: Audit Table]
// Отдельный поток для Dashboard (может быть на другой вкладке)
[Link In: "To Dashboard"] -> [Dashboard Nodes]
Код для узла `Function: "Prepare Internal Temp"`:
// Контракт входящего сообщения от 1-Wire In:
// msg.payload = <число> (температура в °C)
let temp_in = parseFloat(msg.payload);
// Валидация: проверяем, что значение в разумных пределах
if (isNaN(temp_in) || temp_in < -20 || temp_in > 60) {
node.status({ fill: "red", shape: "dot", text: "Некорректная температура: " + msg.payload });
node.error("Некорректное значение температуры с датчика DS18B20: " + msg.payload, msg);
// Отправляем ошибку в аудит
let auditError = {
event: "SENSOR_ERROR",
timestamp: Date.now(),
source: "ds18b20_internal",
error_code: "INVALID_VALUE",
error_message: "Received out-of-range temperature",
details: `Raw value: ${msg.payload}`
};
// Используем третий выход для аудита ошибок
return [null, null, { payload: auditError, topic: "audit_log" }];
}
// Сохраняем значение в контекст, чтобы FSM мог его использовать
flow.set("temperature_internal", temp_in);
// Формируем исходящее сообщение по контракту
// msg.payload = { value: 23.5, source: "ds18b20_internal", ts: 1678886400000, unit: "°C" }
msg.payload = {
value: temp_in,
unit: "°C",
source: "ds18b20_internal",
ts: Date.now()
};
msg.topic = "telemetry/climate/internal_temperature";
node.status({ fill: "green", shape: "dot", text: "OK: " + temp_in + "°C" });
return msg;
Код для узла `Function: "FSM Logic"`:
// Получаем текущее состояние FSM из контекста. При первом запуске - IDLE.
let state = flow.get("fsm_state") || "IDLE";
// Получаем текущие температуры из контекста (их туда запишут другие потоки)
let temp_in = flow.get("temperature_internal");
let temp_out = flow.get("temperature_external");
// Контракт сообщения для аудита
let auditMessage = {
event: "FSM_STATE_CHECK",
timestamp: Date.now(),
current_state: state,
internal_temp: temp_in,
external_temp: temp_out,
transition: null,
action: null,
details: "Checking FSM conditions"
};
// Если какие-то данные еще не пришли, выходим
if (temp_in === undefined || temp_out === undefined) {
node.status({ fill: "yellow", shape: "ring", text: "Ожидание данных..." });
auditMessage.details = "Waiting for all sensor data";
// Отправляем в аудит только если это не первый запуск и данные действительно отсутствуют
if (flow.get("fsm_initialized")) {
node.send([null, null, { payload: auditMessage, topic: "audit_log" }]);
}
return null;
}
// Устанавливаем флаг инициализации FSM
flow.set("fsm_initialized", true);
let original_state = state;
let command = null; // Команда для реле
// --- Логика переходов FSM ---
switch (state) {
case "IDLE":
if (temp_in > 25 && temp_out > 22) {
state = "COOLING";
command = true; // Включить реле
auditMessage.transition = `${original_state} -> ${state}`;
auditMessage.action = "Turn ON AC";
auditMessage.details = `Internal temp (${temp_in}°C) > 25°C AND External temp (${temp_out}°C) > 22°C`;
}
break;
case "COOLING":
if (temp_in < 23) {
state = "IDLE";
command = false; // Выключить реле
auditMessage.transition = `${original_state} -> ${state}`;
auditMessage.action = "Turn OFF AC";
auditMessage.details = `Internal temp (${temp_in}°C) < 23°C`;
}
break;
}
// Сохраняем новое состояние в контекст
flow.set("fsm_state", state);
// --- Формирование исходящих сообщений ---
let msgRelay = null;
let msgDashboard = null;
let msgAudit = null;
// 1. Сообщение для управления реле (только если состояние изменилось)
if (state !== original_state) {
msgRelay = {
payload: {
value: command,
source: "climate_fsm",
ts: Date.now()
},
topic: "command/ac/set"
};
// Добавляем информацию о команде в аудит, если она была
if (auditMessage.action) {
auditMessage.action_status = "sent";
}
msgAudit = { payload: auditMessage, topic: "audit_log" };
} else {
// Если состояние не изменилось, но мы хотим логировать периодические проверки
// Можно добавить условие, чтобы не логировать слишком часто
// Например, логировать только если прошло X минут с последнего лога
// Или логировать только изменения состояния
// Для данной ЛР логируем только изменения состояния
}
// 2. Сообщение для обновления Dashboard (отправляем всегда)
msgDashboard = {
payload: {
state: state,
temp_in: temp_in,
temp_out: temp_out,
timestamp: Date.now()
},
topic: "telemetry/climate/status"
};
// 3. Обновляем статус узла для визуальной диагностики
node.status({ fill: "green", shape: "dot", text: `State: ${state} | In: ${temp_in}°C, Out: ${temp_out}°C` });
// Отправляем сообщения на соответствующие выходы
return [msgRelay, msgDashboard, msgAudit];
💡 Совет: Настройте узел `Function` на 3 выхода. Первый — для команд реле, второй — для данных на Dashboard, третий — для аудита. Это делает поток более читаемым.
Конфигурация узла `Relay Out: AC Control`:- Используйте узел `rpi gpio out` (если реле подключено к GPIO) или `modbus-write` (если реле Modbus).
- Настройте его на управление соответствующим реле (например, `RL-01`).
- Убедитесь, что он принимает булево значение (`true` для включения, `false` для выключения) из `msg.payload.value`.
- Контракт сообщения: `msg.payload = { value: true/false, source: "climate_fsm", ts:
}`
Шаг 2: Интеграция с внешним API погоды
Используем бесплатный API от OpenWeatherMap для получения прогноза погоды.
* Зарегистрируйтесь на сайте OpenWeatherMap.
* Перейдите в раздел "API keys" и скопируйте ваш ключ.
* `Inject`: Настройте на запуск каждые 15 минут.
* `http request`:
* Method: `GET`
* URL: `https://api.openweathermap.org/data/2.5/weather?q=Moscow&appid=ВАШ_API_КЛЮЧ&units=metric`
(Замените `Moscow` на ваш город и `ВАШ_API_КЛЮЧ` на ваш ключ).
* Return: `a parsed JSON object`.
* `Function: "Prepare External Temp"`: Этот узел будет извлекать температуру из ответа API, валидировать ее и сохранять в контекст.
Код для узла `Function: "Prepare External Temp"`:// Контракт сообщения от API (пример):
// msg.payload = { "main": { "temp": 19.5, ... }, "cod": 200, ... }
// 1. Валидация ответа
if (msg.statusCode !== 200 || !msg.payload || msg.payload.cod !== 200) {
node.error("Ошибка API погоды: " + msg.statusCode + " - " + (msg.payload ? JSON.stringify(msg.payload) : "No payload"), msg);
node.status({ fill: "red", shape: "dot", text: "API Error: " + msg.statusCode });
// Отправляем ошибку в аудит
let auditError = {
event: "API_ERROR",
timestamp: Date.now(),
source: "openweathermap_api",
error_code: msg.statusCode || "UNKNOWN",
error_message: msg.payload ? (msg.payload.message || JSON.stringify(msg.payload)) : "No payload",
details: "Failed to fetch external temperature"
};
// Используем третий выход для аудита ошибок
return [null, null, { payload: auditError, topic: "audit_log" }];
}
if (msg.payload && msg.payload.main && typeof msg.payload.main.temp === 'number') {
let temp_out = msg.payload.main.temp;
// 2. Сохраняем значение в контекст, чтобы FSM мог его использовать
flow.set("temperature_external", temp_out);
// 3. Формируем сообщение по нашему внутреннему контракту
// msg.payload = { value: 19.5, source: "openweathermap_api", ts: 1678886400000, unit: "°C" }
let newMsg = {
payload: {
value: temp_out,
unit: "°C",
source: "openweathermap_api",
ts: Date.now()
},
topic: "telemetry/weather/temperature"
};
node.status({ fill: "green", shape: "dot", text: "OK: " + temp_out + "°C" });
// Отправляем сообщение дальше, например, на вход FSM
return newMsg;
} else {
node.error("Некорректный формат ответа от API погоды", msg);
node.status({ fill: "red", shape: "dot", text: "Invalid API response" });
// Отправляем ошибку в аудит
let auditError = {
event: "API_ERROR",
timestamp: Date.now(),
source: "openweathermap_api",
error_code: "PARSE_ERROR",
error_message: "Invalid JSON structure or missing temperature data",
details: "Failed to parse external temperature from API response"
};
// Используем третий выход для аудита ошибок
return [null, null, { payload: auditError, topic: "audit_log" }];
}
⚠️ Предупреждение: Никогда не храните API ключи прямо в коде узла `Function`. Используйте переменные окружения или узел `Change` для установки URL, чтобы ваш ключ не был виден в экспортированном потоке. Для этого можно создать узел `Change` перед `http request` и установить `msg.url` из переменной окружения `process.env.OPENWEATHER_API_KEY`.
Шаг 3: Визуализация данных на Dashboard
Для быстрой визуализации используем встроенный `node-red-dashboard`.
* `ui_gauge` (Датчик температуры в помещении):
* Подключите к выходу `Function: "Prepare Internal Temp"`.
* `Group`: выберите созданную группу.
* `Label`: "Температура в помещении".
* `Value format`: `{{value | number:1}} °C`.
* `Range`: 0-40.
* `ui_text` (Температура на улице):
* Подключите к выходу `Function: "Prepare External Temp"`.
* `Label`: "Температура на улице".
* `Value format`: `{{msg.payload.value | number:1}} °C`.
* `ui_text` (Состояние системы):
* Подключите ко второму выходу узла `Function: "FSM Logic"` (который отправляет `msgDashboard`).
* `Label`: "Режим работы".
* `Value format`: `{{msg.payload.state}}`.
* `ui_switch` (Ручное управление AC - опционально):
* Позволяет вручную включать/выключать кондиционер, переопределяя FSM.
* Подключите к `Relay Out: AC Control`.
* Важно: Для реализации ручного управления потребуется дополнительная логика в FSM, чтобы учитывать ручное переключение и, возможно, возвращаться в автоматический режим через некоторое время. Это усложнение можно реализовать после успешного выполнения основной задачи.
Теперь, открыв `http://
Шаг 4: Тестирование и оценка производительности
Профессиональный подход требует не только создания, но и тщательной проверки системы.
📋 Чек-лист для тестирования и сдачи системы:
| ID теста | Описание теста (Test Case) | Ожидаемый результат (Expected Result) | Статус |
| :------- | :--------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------- | :----- |
| Функциональные тесты |
| FT-01 | Имитировать температуру в помещении `26°C` (через `Inject` с нужным `msg.payload`). Температура на улице `23°C`. | Система переходит в состояние `COOLING`. Реле кондиционера включается. На Dashboard отображается статус "COOLING". Запись в логе. | |
| FT-02 | Имитировать температуру в помещении `22°C`. | Система переходит в состояние `IDLE`. Реле кондиционера выключается. На Dashboard отображается статус "IDLE". Запись в логе. | |
| FT-03 | Имитировать температуру в помещении `26°C`, но на улице `15°C`. | Система остается в состоянии `IDLE`. Реле не включается. | |
| FT-04 | Имитировать температуру в помещении `24°C`. | Система остается в текущем состоянии (если `IDLE`, то `IDLE`; если `COOLING`, то `COOLING`). Реле не меняет состояние. | |
| Тесты на обработку ошибок |
| ET-01 | Отключить контроллер от интернета. Дождаться срабатывания потока запроса погоды. | В логе Node-RED (и в вашей БД `audit_log`) появляется ошибка API. Система продолжает работать на основе последних валидных данных. | |
| ET-02 | Физически отключить датчик DS18B20 от контроллера. | В логе появляется ошибка чтения 1-Wire. Система переходит в безопасное состояние (например, `IDLE`) и сообщает об ошибке. | |
| ET-03 | Отправить в `Function: "Prepare Internal Temp"` некорректное значение (например, строку "abc" или число 1000). | Узел `Function` должен отфильтровать некорректное значение, вывести ошибку в лог и не передать его дальше. | |
| Тесты пользовательского интерфейса |
| UI-01 | Открыть панель Dashboard в браузере. | Все виджеты отображаются корректно. Данные обновляются в реальном времени (или с заданной периодичностью). | |
| UI-02 | Изменить значения температур через `Inject` узлы. | Виджеты Dashboard должны оперативно обновить отображаемые значения. | |
Мини-runbook «Если что-то не работает»
- Проблема: API погоды не возвращает данные, в логе ошибка `401 Unauthorized`.
- Проблема: Логика FSM не срабатывает, хотя все температуры верные.
* Проверьте, что все `Link In` и `Link Out` узлы имеют правильные имена и соединены.
- Проблема: Датчик DS18B20 показывает `85` или `-127`.
- Проблема: Панель Dashboard (`/ui`) пуста или не загружается.
- Проблема: Записи в `audit_log` не появляются в MySQL.
- Проблема: Кондиционер включается/выключается слишком часто (короткие циклы).
Лабораторная работа 1: Реализация базового климат-контроля
Цель: Создать и протестировать основную логику климат-контроля с использованием FSM и имитацией внешних данных. Задание:* `Inject: "Внутренняя температура"`: отправляет число (например, 26) каждые 5 секунд.
* `Inject: "Внешняя температура"`: отправляет число (например, 23) каждые 10 секунд.
* `Function: "Set Internal Temp"`: принимает `msg.payload` от "Внутренней температуры" и сохраняет его в `flow.temperature_internal`.
* `Function: "Set External Temp"`: принимает `msg.payload` от "Внешней температуры" и сохраняет его в `flow.temperature_external`.
// FLOW-LAB1-CLIMATE-FSM-SIM
// Вкладка "Климат-контроль (ЛАБ1)"
[Inject: "Внутренняя температура"] --> [Function: "Set Internal Temp"] --> [Link Out: "To FSM Input Lab1"]
[Inject: "Внешняя температура"] --> [Function: "Set External Temp"] --> [Link Out: "To FSM Input Lab1"]
[Inject: "Запуск FSM"] --> [Link In: "To FSM Input Lab1"] --> [Function: "FSM Logic"] --+--> [Debug: "Relay Commands Lab1"]
|
+--> [Debug: "Dashboard Data Lab1"]
|
+--> [Debug: "Audit Log Lab1"]
Рубрика оценивания:
- 5 баллов: Все узлы настроены корректно, FSM переключается между состояниями `IDLE` и `COOLING` в соответствии с условиями.
- 3 балла: FSM работает, но есть незначительные ошибки в логике или контрактах сообщений.
- 1 балл: FSM не работает или работает некорректно.
Лабораторная работа 2: Полная интеграция и визуализация
Цель: Интегрировать реальные датчики, внешний API и создать Dashboard для мониторинга. Задание:* WIRING-SENS-DS18B20: Подключение датчика DS18B20 к универсальному входу HI.
//========= WIRING-SENS-DS18B20: DS18B20 to UI =========
// Используется универсальный вход UI-01
// Подтягивающий резистор 4.7 кОм между VCC и Data обязателен.
[CTRL:HI-Core] (SENS:Temp:DS18B20-01)
Клемма Цвет
+3.3V/5V --------- (Красный) ---- VCC
UI-01 --------- (Желтый) ----- DATA
GND --------- (Черный) ----- GND
* WIRING-ACT-AC-RELAY: Подключение кондиционера через реле HI.
//========= WIRING-ACT-AC-RELAY: AC Unit Control via Relay =========
// Управление кондиционером через реле RL-01 (сухой контакт или силовое)
// Предполагается, что кондиционер имеет вход для внешнего управления (например, "сухой контакт" или низковольтное управление).
// Если кондиционер управляется по 230V, используйте соответствующее силовое реле и соблюдайте правила электробезопасности.
[CTRL:HI-Core]
Клемма
RL-01 (C) --------- COM (AC Unit Control Input)
RL-01 (NO) -------- NO (AC Unit Control Input)
// Если кондиционер управляется по 230V:
// Щит АВР [CTRL:HI-Core]
// ~L~ --------- L
// ~N~ --------- N
// ~PE~ -------- PE
//
// ~L~ ---+-- C (RL-01)
// \-- NO (RL-01) --- ~L~ --- L (AC Unit)
// ~N~ ----------------------------- N (AC Unit)
* SQL-схема для `audit_log`:
CREATE TABLE IF NOT EXISTS audit_log (
id INT AUTO_INCREMENT PRIMARY KEY,
timestamp BIGINT NOT NULL,
event VARCHAR(255) NOT NULL,
source VARCHAR(255),
error_code VARCHAR(50),
error_message TEXT,
details TEXT
);
* Код для узла `Function: "Format Audit Message"`:
// Контракт входящего сообщения:
// msg.payload = { event: "FSM_STATE_CHECK", timestamp: ..., ... }
// msg.payload = { event: "API_ERROR", timestamp: ..., ... }
if (msg.payload && msg.payload.event) {
let auditEntry = {
timestamp: msg.payload.timestamp || Date.now(),
event: msg.payload.event,
source: msg.payload.source || null,
error_code: msg.payload.error_code || null,
error_message: msg.payload.error_message || null,
details: msg.payload.details || null
};
// Формируем сообщение для MySQL
msg.topic = "INSERT INTO audit_log (timestamp, event, source, error_code, error_message, details) VALUES (?, ?, ?, ?, ?, ?)";
msg.payload = [
auditEntry.timestamp,
auditEntry.event,
auditEntry.source,
auditEntry.error_code,
auditEntry.error_message,
auditEntry.details
];
return msg;
}
return null;
// FLOW-CLIMATE-CONTROL-FULL
// Вкладка "Климат-контроль"
[Inject: 10s] -> [1-Wire In: DS18B20] -> [Function: "Prepare Internal Temp"] -> [Link Out: "To FSM Input"]
[Inject: 15m] -> [HTTP Request: Weather API] -> [Function: "Prepare External Temp"] -> [Link Out: "To FSM Input"]
[Link In: "To FSM Input"] -> [Function: "FSM Logic"] --+--> [Relay Out: AC Control (RL-01)]
|
+--> [Link Out: "To Dashboard"]
|
+--> [Link Out: "To Audit Log"]
[Catch: All Nodes] -> [Function: "Format Error"] -> [Link Out: "To Audit Log"]
// Вкладка "Dashboard"
[Link In: "To Dashboard"] --> [ui_gauge: "Внутренняя температура"]
[Link In: "To Dashboard"] --> [ui_text: "Внешняя температура"]
[Link In: "To Dashboard"] --> [ui_text: "Режим работы"]
// Вкладка "Audit Log"
[Link In: "To Audit Log"] --> [Function: "Format Audit Message"] --> [MySQL Out: Audit Table]
Рубрика оценивания:
- 10 баллов: Все компоненты интегрированы и работают стабильно. Dashboard отображает актуальные данные. Логирование в MySQL функционирует. Все тесты пройдены успешно.
- 7 баллов: Основные компоненты работают, но есть незначительные проблемы (например, не все тесты пройдены, небольшие ошибки в Dashboard или логировании).
- 4 балла: Система частично функционирует, но есть серьезные недоработки в интеграции или логике.
COURSE-16-M07-QUIZ — Тест по модулю "Интеграция с внешними сервисами, визуализация и тестирование"
a) Контракт сообщения
b) Обработка ошибок
c) Конечный автомат (FSM)
d) Переиспользуемый компонент
a) `msg.payload`
b) `global context`
c) `flow context`
d) `node context`
a) Для ускорения выполнения потоков
b) Для сохранения состояния переменных контекста после перезагрузки контроллера
c) Для обмена данными между разными экземплярами Node-RED
d) Для шифрования конфиденциальных данных
a) IP-адрес сервера
b) API ключ и документацию по запросам/ответам
c) Имя пользователя и пароль
d) MAC-адрес сервера
a) `mqtt in`
b) `http in`
c) `http request`
d) `websocket in`
a) Node-RED завершит работу.
b) Сообщение об ошибке будет выведено в консоль Node-RED, и узел `Catch` сможет его перехватить.
c) Сообщение будет отправлено на следующий узел в потоке.
d) Ничего не произойдет.
a) `ui_button`
b) `node-red-dashboard` (палитра)
c) `http response`
d) `template`
a) Для отправки сообщений в `Debug` панель.
b) Для визуального отображения текущего состояния узла в редакторе Node-RED.
c) Для сохранения данных в контексте.
d) Для выполнения HTTP-запросов.
a) Соглашение о лицензировании Node-RED.
b) Строгая структура `msg.payload` и других свойств `msg` для стандартизации данных.
c) Договор между узлами о порядке их выполнения.
d) Метод шифрования сообщений.
a) `Debug`
b) `Switch`
c) `Catch`
d) `Delay`
Мини-runbook «Если что-то не работает»
- Проблема: API погоды не возвращает данные, в логе ошибка `401 Unauthorized`.
- Проблема: Логика FSM не срабатывает, хотя все температуры верные.
* Проверьте, что все `Link In` и `Link Out` узлы имеют правильные имена и соединены.
- Проблема: Датчик DS18B20 показывает `85` или `-127`.
- Проблема: Панель Dashboard (`/ui`) пуста или не загружается.
- Проблема: Записи в `audit_log` не появляются в MySQL.
- Проблема: Кондиционер включается/выключается слишком часто (короткие циклы).