Состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD

Пункт 18 ревью 2026-09-28 21:32 (docs/changes/027): после отката ROS
плата остаётся на более новой прошивке, а приложение сравнивало версии
на неравенство и показывало откат как обновление.
- devices.fw_state: unknown/update/downgrade/current; has_fw_update и
  фильтры не считают откат обновлением; метка «в ROS: X».
- upgrade_firmware не понижает прошивку; общая запись — _flash_firmware.
- Задача fw_downgrade: проверка → обязательный бэкап → запись →
  перезагрузка; API POST /devices/{id}/firmware/downgrade и
  /batch/fw_downgrade (target_version обязателен).
- UI: «Откатить прошивку…» (строка и группа), общее окно отката с kind.

Тесты: 39 из 39. Стенд: временное недоступное устройство — 422/202,
задачи failed на проверке, бэкапов нет. Реальная запись прошивки при
откате и ручная проверка UI не выполнялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-29 00:04:51 +03:00
1 parent 2dda12e06c
commit 5f7bd6588a
16 files changed
+393 -52

No files matched your search

+18 -9
View File
@@ -79,7 +79,15 @@ docs/changes/ планы и итоги доработок docs/reviews/
| Последняя проверка обновлений завершилась ошибкой | «проверка не удалась», текст ошибки в подсказке |
| Проверка ещё не выполнялась (или у устройства нет интернета) | «—» |
«Upgrade FW» — версия прошивки для обновления или «актуально». Устройство проверяет обновления на серверах MikroTik.
**Колонка «Upgrade FW»** (изменение 027)
| Состояние | Отображение |
|---|---|
| В установленную ROS встроена более новая прошивка платы | «↑ X» |
| Записана версия, встроенная в ROS | «актуально» |
| Прошивка платы новее встроенной в ROS (например, плата не откатилась вместе с ROS — 023) | «в ROS: X» — не обновление; откат — отдельной операцией |
| Нет RouterBOARD firmware (CHR) или устройство не опрошено | «—» |
Прошивка сравнивается с версией, встроенной в установленную ROS (`upgrade-firmware`), а не с серверами MikroTik: `/system/routerboard/upgrade` только записывает то, что уже скачано вместе с ROS.
**Группы и фильтры**
- Устройство — в одной группе или «Без группы». Вкладки групп со счётчиками, страница «Группы»; группу можно создать при добавлении устройства.
@@ -87,13 +95,13 @@ docs/changes/ планы и итоги доработок docs/reviews/
**Операции и задачи**
- Действия по устройству — меню «⋯» строки; над выбранными — полоса инструментов; через API — и над целой группой.
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
- Бэкап, обновление ROS и FW, смена канала для группы, откат ROS, откат FW (027) выполняются задачами (карточка «Задачи», `/api/v1/jobs`).
- Обновление ROS: устройство перезагружается после скачивания. Обновление FW: перезагрузка, как только в журнале устройства появилась запись «Firmware upgraded successfully…».
- Штатное обновление не выполняет откат: версия канала старше установленной — «Обновление не требуется».
- Штатное обновление не выполняет откат: версия канала (или встроенной прошивки) старше установленной/записанной — «Обновление не требуется», с подсказкой на соответствующий откат.
**Откат ROS до версии канала**
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Для одного устройства — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X»), для группы — пункт в меню «Обновление»; устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
- Задача: проверка на устройстве (версия канала совпадает с введённой и старше установленной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и установка.
**Откат ROS до версии канала / откат FW до версии ROS**
- Только осознанно: окно с вводом целевой версии, кнопка «Откатить» активна после ввода. Одно окно на оба вида отката (параметр `kind`): для ROS — «Откатить ROS…» в меню «⋯» (виден только в состоянии «канал: X») и пункт в меню «Обновление»; для FW (изменение 027) — «Откатить прошивку…» (виден в состоянии «в ROS: X») и «Откатить прошивку до версии ROS…» там же. Устройства, которые откатывать нельзя, показаны списком «Не будут затронуты».
- Задача: проверка на устройстве (целевая версия совпадает с введённой и старше записанной) → **обязательный бэкап** (сбой прерывает) → повторная проверка и запись. Для FW прошивка берётся из установленной ROS, а не с серверов MikroTik.
**Бэкапы**
- Пара файлов: `.backup` (без шифрования) и `.rsc` (с `show-sensitive` — пароли и ключи). Сервер создаёт их через REST, скачивает, загружает в S3 и удаляет с устройства.
@@ -119,8 +127,8 @@ docs/changes/ планы и итоги доработок docs/reviews/
| Область | Эндпоинты |
|---|---|
| Устройства | `GET\|POST /devices` (фильтры `group`, `q`, `status`, `updates`, `channel`), `GET\|PATCH\|DELETE /devices/{id}` (смена имени — 400), `POST /devices/refresh`, `POST /devices/{id}/refresh` |
| Операции | `POST /devices/{id}/backups`, `PUT /devices/{id}/update/channel`, `POST /devices/{id}/update/install`, `POST /devices/{id}/firmware/upgrade`, `POST /devices/{id}/update/downgrade` (`{"target_version"}` обязателен) |
| Групповые | `POST /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade` (`target_version`) — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
| Операции | `POST /devices/{id}/backups`, `PUT /devices/{id}/update/channel`, `POST /devices/{id}/update/install`, `POST /devices/{id}/firmware/upgrade`, `POST /devices/{id}/update/downgrade`, `POST /devices/{id}/firmware/downgrade` (027) — оба отката `{"target_version"}` обязателен |
| Групповые | `POST /batch/{backup\|ros_update\|fw_update}`, `PUT /batch/channel` (`channel`), `POST /batch/ros_downgrade`, `POST /batch/fw_downgrade` (027) — оба `target_version` — тело `{"device_ids": [...]}` и/или `{"group_id": "grp_…"}` |
| Группы | `GET\|POST /groups`, `PATCH\|DELETE /groups/{id}` (устройства остаются без группы) |
| Бэкапы | `GET /backups` (фильтры `device_id`, `group`, `kind`, `date_from`, `date_to`, `q`; `refresh=1` — минуя кэш), `GET /backups/download?key=`, `DELETE /backups?key=`, `POST /backups/delete` (`{"keys": [...]}` → `{"deleted", "failed"}`) |
| Задачи | `GET /jobs`, `GET /jobs/{id}` |
@@ -169,7 +177,7 @@ curl -s -H "Authorization: Bearer $API_TOKEN" http://localhost:8000/api/v1/devic
- Светлая и тёмная темы — по настройке системы.
## Тесты
36 тестов по модулям предметных областей (`tests/test_*.py`), фоновый опрос выключен; стенд не нужен (временная SQLite,
39 тестов по модулям предметных областей (`tests/test_*.py`), фоновый опрос выключен; стенд не нужен (временная SQLite,
RouterOS и S3 — заглушки). Тест-линтер (`test_architecture.py`) не допускает синхронных обращений к БД в `async`-коде.
```bash
python3 -m venv venv && venv/bin/pip install -r requirements.txt
@@ -209,6 +217,7 @@ venv/bin/python -m pytest -q tests/test_backups.py # один модуль
| 024 | Оптимизация README | [план](docs/changes/024-readme-restructure/plan.md) · [итог](docs/changes/024-readme-restructure/summary.md) |
| 025 | Доверенные прокси (реальный IP клиента), параметризация порта | [план](docs/changes/025-trusted-proxies-port/plan.md) · [итог](docs/changes/025-trusted-proxies-port/summary.md) |
| 026 | Тесты по модулям предметных областей | [план](docs/changes/026-split-tests/plan.md) · [итог](docs/changes/026-split-tests/summary.md) |
| 027 | Состояния «Upgrade FW», откат прошивки RouterBOARD до версии ROS | [план](docs/changes/027-fw-downgrade/plan.md) · [итог](docs/changes/027-fw-downgrade/summary.md) |
## Отчёты ревью
- [Ревью кодовой базы 2026-09-27](docs/reviews/2026-09-27-codebase-review.md) (→ 018–021)
+15
View File
@@ -211,6 +211,13 @@ async def upgrade_firmware(device_id: str):
return {"job_ids": await jobs.start_jobs("fw_update", [device_id])}
@router.post("/devices/{device_id}/firmware/downgrade", status_code=202)
async def downgrade_firmware(device_id: str, body: DowngradeIn):
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (устройство
перезагрузится); версия проверяется на устройстве в задаче — как и /update/downgrade."""
return {"job_ids": await jobs.start_jobs("fw_downgrade", [device_id], {"target_version": body.target_version})}
@router.post("/devices/{device_id}/update/downgrade", status_code=202)
async def downgrade_update(device_id: str, body: DowngradeIn):
"""Осознанный откат ROS до версии канала (устройство перезагрузится); версия проверяется на
@@ -226,6 +233,14 @@ async def batch_ros_downgrade(body: BatchDowngradeIn):
return {"job_ids": await jobs.start_jobs("ros_downgrade", device_ids, {"target_version": body.target_version})}
@router.post("/batch/fw_downgrade", status_code=202)
async def batch_fw_downgrade(body: BatchDowngradeIn):
"""Изменение 027: групповой откат прошивки — отдельный эндпоинт с обязательной target_version, по образцу
/batch/ros_downgrade; регистрируется раньше /batch/{action}."""
device_ids = await asyncio.to_thread(body.resolve)
return {"job_ids": await jobs.start_jobs("fw_downgrade", device_ids, {"target_version": body.target_version})}
@router.post("/batch/{action}", status_code=202)
async def batch(action: Literal["backup", "ros_update", "fw_update"], body: BatchIn):
"""Групповая операция над списком устройств."""
+53 -11
View File
@@ -205,16 +205,12 @@ async def _booted_at(c: RosClient) -> datetime | None:
return now - timedelta(seconds=up) if now and up is not None else None
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
поэтому по нему завершение определить нельзя."""
board = await get_routerboard(c)
if str(board.get("routerboard", "false")) != "true":
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
if board.get("current-firmware") == board.get("upgrade-firmware"):
return f"Прошивка актуальна ({board.get('current-firmware')})"
async def _flash_firmware(c: RosClient, wait_seconds: int, poll: float) -> str | None:
"""Записывает встроенную в ROS прошивку (POST system/routerboard/upgrade) и ждёт появления FW_DONE_MSG
в журнале, либо обнаруживает, что запись уже была сделана после последней загрузки (изменение 027: общая
часть upgrade_firmware и downgrade_firmware — команда и признак завершения одни и те же, отличается только
итоговое сообщение). Возвращает готовое сообщение для уже записанной прошивки, иначе None — сообщение о
свежей записи формирует вызывающий код (текст различается для обновления и отката)."""
before = await _fw_done_entries(c)
booted = await _booted_at(c)
if booted and any((t := _ts(e.get("time"))) and t >= booted for e in before):
@@ -227,11 +223,57 @@ async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float =
for _ in range(int(wait_seconds / poll) if poll else 1000):
if any(e[".id"] not in known for e in await _fw_done_entries(c)):
await _reboot(c)
return f"Прошивка обновлена до {board.get('upgrade-firmware')}, перезагрузка отправлена"
return None
await asyncio.sleep(poll)
raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время")
async def upgrade_firmware(c: RosClient, wait_seconds: int = 180, poll: float = 2.0) -> str:
"""Обновляет FW и перезагружает устройство, как только в журнале появилась запись
«Firmware upgraded successfully…». current-firmware до перезагрузки не меняется,
поэтому по нему завершение определить нельзя."""
board = await get_routerboard(c)
if str(board.get("routerboard", "false")) != "true":
return "Устройство без RouterBOARD firmware (например, CHR) — пропущено"
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
if current == upgrade:
return f"Прошивка актуальна ({current})"
if version_newer(current, upgrade):
# изменение 027: плата записана прошивкой новее встроенной в установленную ROS (например, после отката
# ROS) — это не обновление, а откат; штатное обновление его не выполняет
return (f"Прошивка платы {current} новее встроенной в ROS {upgrade} — обновление не требуется; "
f"откат — «Откатить прошивку…»")
already = await _flash_firmware(c, wait_seconds, poll)
return already or f"Прошивка обновлена до {upgrade}, перезагрузка отправлена"
async def check_fw_downgrade(c: RosClient, target_version: str) -> tuple[str | None, str | None]:
"""Изменение 027: проверяет осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS —
по образцу check_downgrade. Не RouterBOARD — откат недоступен; встроенная версия должна совпадать с
подтверждённой пользователем и быть действительно старше записанной на плате (иначе это не откат). Запись не
выполняет — чтобы не делать бэкап устройства, которое откатывать нельзя. Возвращает (current, upgrade)."""
board = await get_routerboard(c)
if str(board.get("routerboard", "false")) != "true":
raise RosError("Устройство без RouterBOARD firmware (например, CHR) — откат прошивки недоступен")
current, upgrade = board.get("current-firmware"), board.get("upgrade-firmware")
if upgrade != target_version:
raise RosError(f"Прошивка в ROS {upgrade} не совпадает с подтверждённой {target_version} — откат отменён")
if not version_newer(current, upgrade):
raise RosError(f"Прошивка в ROS {upgrade} не старше записанной {current} — это не откат")
return current, upgrade
async def downgrade_firmware(c: RosClient, target_version: str, wait_seconds: int = 180, poll: float = 2.0) -> str:
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS (после
отката ROS, например изменение 023, плата остаётся на прежней прошивке). Технически — та же команда
/system/routerboard/upgrade, что и обычное обновление: она записывает версию, встроенную в установленную
ROS, в том числе более старую."""
current, upgrade = await check_fw_downgrade(c, target_version)
already = await _flash_firmware(c, wait_seconds, poll)
return already or f"Откат прошивки {current} → {upgrade} записан, перезагрузка отправлена"
async def _reboot(c: RosClient) -> None:
try:
await c.post("system/reboot", timeout=10)
+15 -1
View File
@@ -170,8 +170,22 @@ def ros_state(st: dict) -> str:
return "current"
def fw_state(st: dict) -> str:
"""Состояние колонки «Upgrade FW» (изменение 027): unknown — нет fw_current/fw_upgrade (CHR или не опрошено);
update — в установленную ROS встроена более новая прошивка; downgrade — записанная на плате прошивка новее
встроенной в ROS (например, после отката ROS, изменение 023 — плата не откатывается вместе с ROS); иначе
current. По образцу ros_state."""
if not (st.get("fw_current") and st.get("fw_upgrade")):
return "unknown"
if version_newer(st["fw_upgrade"], st["fw_current"]):
return "update"
if version_newer(st["fw_current"], st["fw_upgrade"]):
return "downgrade"
return "current"
def has_fw_update(st: dict) -> bool:
return bool(st.get("fw_current") and st.get("fw_upgrade") and st["fw_upgrade"] != st["fw_current"])
return fw_state(st) == "update"
def filter_devices(devs: list[Device], group: str = "", q: str = "", status: str = "",
+1
View File
@@ -13,6 +13,7 @@ JOB_TYPES = {
"fw_update": ops.run_fw_update,
"set_channel": ops.run_set_channel,
"ros_downgrade": ops.run_ros_downgrade,
"fw_downgrade": ops.run_fw_downgrade, # изменение 027
}
_tasks: set[asyncio.Task] = set()
+13
View File
@@ -167,3 +167,16 @@ async def run_ros_downgrade(device_id: str, target_version: str) -> str:
async with devices.open_client(await _conn(device_id)) as c:
install_msg = await ros.downgrade_ros(c, target_version)
return f"{install_msg}. {backup_msg}"
async def run_fw_downgrade(device_id: str, target_version: str) -> str:
"""Изменение 027: осознанный откат прошивки RouterBOARD до версии, встроенной в установленную ROS — та же
последовательность, что и run_ros_downgrade: 1) проверка совпадения с подтверждённой версией и того, что она
действительно старше записанной; 2) обязательный бэкап (сбой прерывает задачу, запись не выполняется);
3) повторная проверка и запись на новом подключении."""
async with devices.open_client(await _conn(device_id)) as c:
await ros.check_fw_downgrade(c, target_version)
backup_msg = await run_backup(device_id)
async with devices.open_client(await _conn(device_id)) as c:
install_msg = await ros.downgrade_firmware(c, target_version)
return f"{install_msg}. {backup_msg}"
+67 -17
View File
@@ -48,6 +48,7 @@ templates.env.globals["ev_entity"] = lambda t: _EV_ENTITY.get(t, t)
templates.env.globals["ICONS"] = ICONS
templates.env.globals["ros_newer"] = version_newer
templates.env.globals["ros_state"] = devices.ros_state
templates.env.globals["fw_state"] = devices.fw_state # изменение 027
class LoginRequired(Exception):
@@ -236,10 +237,15 @@ async def device_action(request: Request, device_id: str, action: str, channel:
@router.post("/ui/downgrade", dependencies=[Depends(require_login)])
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default="")):
"""Откат ROS для выбранных устройств (окно «Откатить ROS…»): из выбранных берутся только те, у кого
в кэше статуса ros_state == downgrade и версия канала совпадает с введённой; иначе окно остаётся
открытым с ошибкой — задачи не создаются."""
async def downgrade(request: Request, device_ids: list[str] = Form(default=[]), target_version: str = Form(default=""),
kind: str = Form(default="ros")):
"""Откат ROS/FW для выбранных устройств (окно «Откатить…», параметризовано kind — изменение 027: без kind
поведение и адреса как раньше): из выбранных берутся только те, у кого в кэше статуса соответствующее
состояние downgrade и целевая версия совпадает с введённой; иначе окно остаётся открытым с ошибкой —
задачи не создаются."""
if kind not in DOWNGRADE_KINDS:
raise ValueError(f"Неизвестный вид отката: {kind}")
k = DOWNGRADE_KINDS[kind]
target_version = target_version.strip()
wanted = set(device_ids)
@@ -247,10 +253,10 @@ async def downgrade(request: Request, device_ids: list[str] = Form(default=[]),
return [d for d in devices.list_devices() if d.id in wanted]
devs = await asyncio.to_thread(_load)
matched = [d for d in devs if devices.ros_state(d.status) == "downgrade" and d.status.get("ros_latest") == target_version]
matched = [d for d in devs if k["state"](d.status) == "downgrade" and d.status.get(k["target_key"]) == target_version]
if not matched:
return _downgrade_dialog(request, devs, "Версия не совпадает с версией канала выбранных устройств")
await jobs.start_jobs("ros_downgrade", [d.id for d in matched], {"target_version": target_version})
return _downgrade_dialog(request, devs, k["mismatch_error"], kind)
await jobs.start_jobs(k["job_type"], [d.id for d in matched], {"target_version": target_version})
return _done(request)
@@ -323,32 +329,76 @@ def dialog_group_edit(request: Request, group_id: str):
return _render(request, "_group_form.html", in_modal=True, group=g, gname=g.name, error=None)
# --- откат ROS до версии канала ---
# --- откат ROS до версии канала / откат FW до версии, встроенной в ROS (изменение 027) ---
def _downgrade_split(devs: list) -> tuple[list, list]:
# Описания видов отката для общего окна _dialog_downgrade.html: функция состояния, ключи установленной/целевой
# версии в кэше статуса, тип фоновой задачи и тексты. Без kind (?kind= отсутствует, поле формы по умолчанию)
# используется "ros" — адреса и поведение отката ROS не меняются.
DOWNGRADE_KINDS: dict[str, dict] = {
"ros": dict(
title="Откатить ROS до версии канала",
state=devices.ros_state,
installed_key="ros_installed",
target_key="ros_latest",
col_label="Установлено → канал",
extra_label="Канал",
extra_key="channel",
job_type="ros_downgrade",
hint="должна совпадать с версией канала выбранных устройств",
warn=("Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки "
"устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. "
"Если версия канала у выбранных устройств различается, откатятся только те, чья версия канала "
"совпадает с введённой ниже — остальные останутся без изменений."),
mismatch_reason="версия канала не старше установленной",
mismatch_error="Версия не совпадает с версией канала выбранных устройств",
),
"fw": dict(
title="Откатить прошивку до версии ROS",
state=devices.fw_state,
installed_key="fw_current",
target_key="fw_upgrade",
col_label="Записано → в ROS",
extra_label=None,
extra_key=None,
job_type="fw_downgrade",
hint="должна совпадать с версией, встроенной в установленную ROS выбранных устройств",
warn=("Перед записью создаётся резервная копия устройства; без неё откат не выполняется. После записи "
"устройство перезагрузится. Прошивка берётся из установленной ROS — не с серверов MikroTik. Если "
"встроенная версия у выбранных устройств различается, откатятся только те, чья версия совпадает "
"с введённой ниже — остальные останутся без изменений."),
mismatch_reason="версия в ROS не старше записанной",
mismatch_error="Версия не совпадает с версией в ROS выбранных устройств",
),
}
def _downgrade_split(devs: list, kind: str = "ros") -> tuple[list, list]:
"""Устройства в состоянии downgrade («будут откатены») и остальные («не будут затронуты», с причиной)."""
k = DOWNGRADE_KINDS[kind]
affected, skipped = [], []
for d in devs:
rst = devices.ros_state(d.status)
if rst == "downgrade":
st = k["state"](d.status)
if st == "downgrade":
affected.append(d)
else:
reason = "нет данных проверки" if rst in ("unknown", "check_error") else "версия канала не старше установленной"
reason = "нет данных проверки" if st in ("unknown", "check_error") else k["mismatch_reason"]
skipped.append((d, reason))
return affected, skipped
def _downgrade_dialog(request: Request, devs: list, error: str | None = None):
affected, skipped = _downgrade_split(devs)
def _downgrade_dialog(request: Request, devs: list, error: str | None = None, kind: str = "ros"):
affected, skipped = _downgrade_split(devs, kind)
return _render(request, "_dialog_downgrade.html", in_modal=True, devs=devs, affected=affected,
skipped=skipped, error=error)
skipped=skipped, error=error, kind=kind, k=DOWNGRADE_KINDS[kind])
@router.get("/ui/dialog/downgrade", response_class=HTMLResponse, dependencies=[Depends(require_login)])
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[])):
def dialog_downgrade(request: Request, device_ids: list[str] = Query(default=[]), kind: str = "ros"):
if kind not in DOWNGRADE_KINDS:
raise ValueError(f"Неизвестный вид отката: {kind}")
wanted = set(device_ids)
devs = [d for d in devices.list_devices() if d.id in wanted]
return _downgrade_dialog(request, devs)
return _downgrade_dialog(request, devs, kind=kind)
# --- группы ---
+7 -3
View File
@@ -13,7 +13,7 @@
{% for d in devices %}
{% set st = d.status %}{% set down = d.online is false %}
{% set rst = ros_state(st) %}
{% set fw_new = st.fw_current and st.fw_upgrade and st.fw_upgrade != st.fw_current %}
{% set fst = fw_state(st) %}
<tr>
<td><input type="checkbox" name="device_ids" value="{{ d.id }}" form="move-form" data-select aria-label="Выбрать {{ d.name }}"></td>
<td>
@@ -39,8 +39,9 @@
</td>
<td class="c-mono">{% if down %}<span class="dash">—</span>{% else %}{{ st.fw_current or "—" }}{% endif %}</td>
<td>
{% if down or not (st.fw_current and st.fw_upgrade) %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
{% elif fw_new %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
{% if down or fst == "unknown" %}<span class="dash" title="Нет RouterBOARD firmware (например, CHR) или устройство не опрошено">—</span>
{% elif fst == "update" %}<span class="badge warn" title="Доступно обновление прошивки до {{ st.fw_upgrade }}">{{ ui.icon("up", 12, 2.2) }}{{ st.fw_upgrade }}</span>
{% elif fst == "downgrade" %}<span class="badge" title="Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}. Работе обычно не мешает; откат — «Откатить прошивку…»">в ROS: {{ st.fw_upgrade }}</span>
{% else %}<span class="okmark">{{ ui.icon("check", 14, 2.2) }}актуально</span>{% endif %}
</td>
<td class="c-small">{% if down %}<span class="dash">—</span>{% else %}{{ st.uptime or "—" }}{% endif %}</td>
@@ -59,6 +60,9 @@
{% endif %}
<button type="button" class="mi warn" hx-post="/ui/devices/{{ d.id }}/fw_update" hx-target="#jobs"
hx-confirm="Обновить прошивку на {{ d.name }}? Устройство перезагрузится сразу после обновления.">Обновить FW<small>Перезагрузка сразу после обновления</small></button>
{% if fst == "downgrade" %}
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw&device_ids={{ d.id }}" hx-target="#modal-body">Откатить прошивку…<small>Прошивка платы {{ st.fw_current }} новее встроенной в ROS {{ st.fw_upgrade }}</small></button>
{% endif %}
<hr>
<details class="sub">
<summary class="mi">Сменить канал…</summary>
+8 -9
View File
@@ -1,19 +1,21 @@
{# Окно осознанного отката ROS до версии канала: одно устройство или группа (devs — исходный выбор). #}
{# Окно осознанного отката ROS/FW: одно устройство или группа (devs — исходный выбор), параметризовано видом
отката kind (ros|fw, изменение 027; по умолчанию ros — прежнее поведение и адреса без изменений). #}
{% import "_ui.html" as ui %}
<form method="post" action="/ui/downgrade" hx-post="/ui/downgrade" hx-target="#modal-body">
<header class="modal-head">
<h2 id="modal-title">Откатить ROS до версии канала</h2>
<h2 id="modal-title">{{ k.title }}</h2>
<button type="button" class="btn ghost-n icon" data-close aria-label="Закрыть">{{ ui.icon("x") }}</button>
</header>
<div class="modal-body">
{% if error %}<div class="form-err" role="alert">{{ error }}</div>{% endif %}
{% for d in devs %}<input type="hidden" name="device_ids" value="{{ d.id }}">{% endfor %}
<input type="hidden" name="kind" value="{{ kind }}">
{% if affected %}
<table class="grid" style="width:100%">
<thead><tr><th>Устройство</th><th>Установлено → канал</th><th>Канал</th></tr></thead>
<thead><tr><th>Устройство</th><th>{{ k.col_label }}</th>{% if k.extra_label %}<th>{{ k.extra_label }}</th>{% endif %}</tr></thead>
<tbody>
{% for d in affected %}
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status.ros_installed }} → {{ d.status.ros_latest }}</td><td>{{ d.status.channel }}</td></tr>
<tr><td>{{ d.name }}</td><td class="c-mono">{{ d.status[k.installed_key] }} → {{ d.status[k.target_key] }}</td>{% if k.extra_label %}<td>{{ d.status[k.extra_key] }}</td>{% endif %}</tr>
{% endfor %}
</tbody>
</table>
@@ -23,11 +25,8 @@
<ul style="margin:4px 0 0 18px">{% for d, reason in skipped %}<li>{{ d.name }} — {{ reason }}</li>{% endfor %}</ul>
</div>
{% endif %}
<div class="note">Перед откатом создаётся резервная копия устройства; без неё откат не выполняется. После установки
устройство перезагрузится, конфигурация версии канала может быть частично несовместима с текущей. Если версия
канала у выбранных устройств различается, откатятся только те, чья версия канала совпадает с введённой ниже —
остальные останутся без изменений.</div>
<label class="lbl"><span>Введите целевую версию <small>должна совпадать с версией канала выбранных устройств</small></span>
<div class="note">{{ k.warn }}</div>
<label class="lbl"><span>Введите целевую версию <small>{{ k.hint }}</small></span>
<input class="input" name="target_version" placeholder="например, 7.23.7" data-enables="#downgrade-confirm" autofocus required></label>
</div>
<footer class="modal-foot">
+1 -1
View File
@@ -18,7 +18,7 @@
<tr>
<td class="c-mono muted" title="{{ j.id }}">{{ j.id|short_id }}</td>
<td style="font-weight:500">{{ j.device_name }}</td>
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS"}.get(j.type, j.type) }}</td>
<td>{{ {"backup": "Бэкап", "ros_update": "Обновление ROS", "fw_update": "Обновление FW", "set_channel": "Смена канала", "ros_downgrade": "Откат ROS", "fw_downgrade": "Откат FW"}.get(j.type, j.type) }}</td>
<td><span class="badge {{ {'running': 'run', 'done': 'ok', 'failed': 'bad'}.get(j.status, '') }}">{{ {"pending": "В очереди", "running": "Выполняется", "done": "Готово", "failed": "Ошибка"}.get(j.status, j.status) }}</span></td>
<td class="msg">{{ j.message }}</td>
<td class="c-small">{{ j.created_at|dt }}</td>
+1
View File
@@ -59,6 +59,7 @@
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить ROS до версии канала…<small>Обязателен бэкап; устройство перезагрузится</small></button>
<button type="button" class="mi warn" hx-post="/ui/batch/fw_update" hx-include="[name=device_ids]:checked" hx-target="#jobs"
hx-confirm="Обновить прошивку на выбранных устройствах? Они перезагрузятся сразу после обновления.">Обновить прошивку (FW)<small>Устройство перезагрузится сразу после обновления</small></button>
<button type="button" class="mi bad" hx-get="/ui/dialog/downgrade?kind=fw" hx-include="[name=device_ids]:checked" hx-target="#modal-body">Откатить прошивку до версии ROS…<small>Обязателен бэкап; устройство перезагрузится</small></button>
</div>
</details>
<details class="menu">
+85
View File
@@ -0,0 +1,85 @@
# План: 027 — состояния колонки «Upgrade FW» и осознанный откат прошивки RouterBOARD
## Context
П. 18 ревью [2026-09-28 21:32](../../reviews/2026-09-28-2132-codebase-review.md). Прошивка RouterBOARD записывается отдельно от ROS:
`current-firmware` — записанная на плате, `upgrade-firmware` — встроенная в установленную ROS. После отката ROS (изменение 023),
например 7.24.4 → 7.23.7, плата остаётся на 7.24.4, а `upgrade-firmware` становится 7.23.7.
Приложение сравнивает прошивки на неравенство (`devices.has_fw_update`, `fw_new` в `_devices.html`), поэтому:
- колонка «Upgrade FW» показывает «↑ 7.23.7» (жёлтая метка «обновление»), хотя это откат;
- фильтры `updates=fw|any|none` и массовое «Обновить прошивку» считают такое устройство обновляемым;
- `ros.upgrade_firmware` без предупреждения запишет более старую прошивку и перезагрузит устройство.
Решения пользователя:
- объём — отображение и запрет понижения в штатном обновлении **плюс** отдельное действие «Откатить прошивку…»;
- откат прошивки — по образцу отката ROS (023): одно устройство и группа (UI и API), окно с вводом целевой версии, бэкап перед записью.
Техническая основа: `/system/routerboard/upgrade` записывает прошивку, встроенную в установленную ROS, в том числе более старую
(так MikroTik описывает откат прошивки: сначала откатить ROS, затем routerboard upgrade). Признак завершения записи — тот же
`FW_DONE_MSG` в журнале; `current-firmware` меняется только после перезагрузки.
## Изменения
### Состояние (`app/services/devices.py`, `app/ui/routes.py`)
- `fw_state(st) -> "unknown" | "update" | "downgrade" | "current"`: `unknown` — нет `fw_current` или `fw_upgrade` (CHR, не опрошено);
`update` — `version_newer(fw_upgrade, fw_current)`; `downgrade` — `version_newer(fw_current, fw_upgrade)`; иначе `current`.
По образцу `ros_state`; экспорт в шаблоны — `templates.env.globals["fw_state"]`, как `ros_state`.
- `has_fw_update(st)` → `fw_state(st) == "update"`. Фильтры `filter_devices` не меняются: `downgrade` не считается обновлением (как у ROS).
### Операции (`app/ros/operations.py`)
- Вынести из `upgrade_firmware` запись и ожидание в `_flash_firmware(c, wait_seconds, poll) -> str | None`
(ветка «уже записана после последней загрузки», `POST system/routerboard/upgrade`, ожидание `FW_DONE_MSG`, `_reboot`), без копирования.
- `upgrade_firmware`: при `version_newer(current, upgrade)` **не записывает** и возвращает сообщение
«Прошивка платы X новее встроенной в ROS Y — обновление не требуется; откат — «Откатить прошивку…»» (задача `done`, как «Прошивка актуальна»).
Массовое обновление FW такие устройства пропускает с этим сообщением.
- `check_fw_downgrade(c, target_version) -> (current, upgrade)`: не RouterBOARD → `RosError("Устройство без RouterBOARD firmware…")`;
`upgrade-firmware != target_version` → `RosError("Прошивка в ROS X не совпадает с подтверждённой Y — откат отменён")`;
не `version_newer(current, upgrade)` → `RosError("Прошивка в ROS X не старше записанной Y — это не откат")`. По образцу `check_downgrade`.
- `downgrade_firmware(c, target_version, wait_seconds=180, poll=2.0) -> str`: `check_fw_downgrade` + `_flash_firmware`;
сообщение «Откат прошивки X → Y записан, перезагрузка отправлена».
### Задачи (`app/services/ops.py`, `app/services/jobs.py`, `_jobs.html`)
- `ops.run_fw_downgrade(device_id, target_version)` — по образцу `run_ros_downgrade`: проверка → `await run_backup(device_id)`
(сбой прерывает задачу) → новое подключение, `downgrade_firmware`. Итог включает сообщение бэкапа.
- `jobs.JOB_TYPES["fw_downgrade"]`; подпись в `_jobs.html` — «Откат FW».
### API (`app/api/v1.py`)
- `POST /api/v1/devices/{id}/firmware/downgrade` — тело `DowngradeIn` → 202 `{"job_ids"}`.
- `POST /api/v1/batch/fw_downgrade` — `BatchDowngradeIn` → 202; регистрировать **до** `/batch/{action}` (как `/batch/ros_downgrade`);
в `Literal` общего `/batch/{action}` не добавлять.
### UI (`app/ui/routes.py`, `_dialog_downgrade.html`, `_devices.html`, `dashboard.html`)
- Колонка «Upgrade FW» по `fw_state`: `update` — как сейчас; `current` — «актуально»; **`downgrade`** — нейтральная `badge`
«в ROS: X» с подсказкой «Прошивка платы Y новее встроенной в ROS X. Работе обычно не мешает; откат — «Откатить прошивку…»»; `unknown` — «—».
- Меню «⋯» строки: пункт «Откатить прошивку…» (класс `mi bad`) только при `fw_state == "downgrade"`, как «Откатить ROS…».
- Меню «Обновление» панели: «Откатить прошивку до версии ROS…» рядом с «Обновить прошивку (FW)».
- Окно — **то же** `_dialog_downgrade.html`, параметризованное видом отката `kind` (`ros` | `fw`, по умолчанию `ros` — прежние адреса
работают без изменений): заголовок, колонки «записано → в ROS», текст предупреждения, поле версии. Описание видов — один словарь
в `routes.py` (функция состояния, ключи установленной/целевой версии, тип задачи, тексты), им пользуются `_downgrade_split`,
`dialog_downgrade` (`?kind=`) и `POST /ui/downgrade` (скрытое поле `kind`; неизвестное значение → 400/422). Новых шаблонов и стилей нет.
- Текст предупреждения для FW: перед записью создаётся бэкап; после записи устройство перезагрузится; прошивка берётся из установленной ROS.
## Тесты (минимально)
- `tests/test_devices.py`: `fw_state` — unknown / update / downgrade / current; `has_fw_update` ложно для `downgrade`.
- `tests/test_operations.py`, один тест по образцу `test_run_ros_downgrade_order_and_failures`: `fw_update` на устройстве в состоянии
downgrade не вызывает `routerboard/upgrade`; `fw_downgrade` с несовпавшей версией — `failed`, бэкапа и записи нет;
с совпавшей — порядок `backup → upgrade → reboot`.
- Одна проверка API: `/batch/fw_downgrade` без `target_version` → 422.
## Документация
README: «Возможности»/«Интерфейс» (состояния «Upgrade FW», откат прошивки), таблица API (два эндпоинта), типы задач, число тестов,
строка 027 в истории изменений; отчёт ревью — п. 18 со ссылкой на 027. `summary.md` — оркестратор.
## Исполнение
Исполнитель (Sonnet): код, тесты, README, пересборка стенда. Тесты не запускает, не коммитит, `.env` не читает.
**Реальную запись прошивки на устройствах не запускать** — её выполняет пользователь в UI.
## Проверка
- `venv/bin/python -m pytest -q` — все зелёные.
- Стенд (8001, `docker compose up -d --build --force-recreate`): контейнер отдаёт новый код (grep по файлу в контейнере).
- Безопасно на стенде: временное недоступное устройство `192.0.2.1` — `POST /api/v1/devices/{id}/firmware/downgrade` → 202, задача `failed`
на проверке, бэкапа нет; без `target_version` → 422; устройство удаляется.
- Ручная проверка — пользователь: колонка «Upgrade FW» на реальных устройствах («актуально»); окно отката FW (неактивная кнопка,
неверная версия, «не будут затронуты»); окно отката ROS не изменилось. Полный сценарий — откат ROS на `5G-AC-BED`,
метка «в ROS: 7.23.7», «Откатить прошивку…», возврат обычным обновлением ROS и FW.
+36
View File
@@ -0,0 +1,36 @@
# Итог: состояния «Upgrade FW» и осознанный откат прошивки RouterBOARD (изменение 027)
План: [`plan.md`](plan.md). Закрывает п. 18 ревью [2026-09-28 21:32](../../reviews/2026-09-28-2132-codebase-review.md): после отката ROS
прошивка платы больше не выдаётся за «обновление», штатное обновление FW её не понижает, откат прошивки — отдельное осознанное действие.
## Что сделано
| Файл | Изменение |
|---|---|
| `app/services/devices.py` | `fw_state(st)`: `unknown` / `update` / `downgrade` / `current` (по `version_newer`); `has_fw_update` = `fw_state == "update"` — фильтры `updates=fw\|any\|none` не считают откат обновлением |
| `app/ros/operations.py` | запись и ожидание `FW_DONE_MSG` вынесены в `_flash_firmware`; `upgrade_firmware` при плате новее встроенной в ROS не пишет и возвращает «обновление не требуется»; `check_fw_downgrade`, `downgrade_firmware` — по образцу `check_downgrade`/`downgrade_ros` |
| `app/services/ops.py`, `jobs.py` | задача `fw_downgrade` (`run_fw_downgrade`): проверка → обязательный бэкап → повторная проверка и запись |
| `app/api/v1.py` | `POST /devices/{id}/firmware/downgrade`, `POST /batch/fw_downgrade` (обязательный `target_version`; до `/batch/{action}`) |
| `app/ui/routes.py`, `_dialog_downgrade.html` | одно окно отката на оба вида: словарь `DOWNGRADE_KINDS` (`ros`/`fw`), параметр `kind` (по умолчанию `ros` — прежние адреса), неизвестный `kind` → 400 |
| `_devices.html`, `dashboard.html`, `_jobs.html` | метка «в ROS: X», пункты «Откатить прошивку…» (строка, только в состоянии `downgrade`) и «Откатить прошивку до версии ROS…» (группа), подпись «Откат FW» |
| `tests/test_devices.py`, `tests/test_operations.py` | состояния `fw_state`; отказ `upgrade_firmware` понижать; порядок `backup → upgrade → reboot`, несовпадение версии — без бэкапа и записи; 422 без `target_version` |
| `README.md`, отчёт ревью | таблица состояний «Upgrade FW», раздел об откате, API, 39 тестов, строка 027; п. 18 — ✅ 027 |
## Найдено и исправлено в ходе цикла
Дефектов на ревью диффа не найдено, возвратов исполнителю не было.
## Проверки
- `pytest`: 39 из 39 (новых 3).
- Стенд (8001, пересоздан): в контейнере новый код (`def fw_state`, `fw_downgrade`, `kind=fw` в шаблонах).
- Временное недоступное устройство `192.0.2.1`: без `target_version` — 422 на обоих эндпоинтах; с версией — 202; обе задачи `fw_downgrade` —
`failed` на проверке (ConnectTimeout), бэкапов нет. Устройство удалено (204).
- Реальные устройства: у всех RouterBOARD `fw_current = fw_upgrade` — «актуально»; CHR — «—».
## Не проверено
- **Реальная запись прошивки при откате** не выполнялась. Не подтверждено, что RouterOS при записи более старой прошивки пишет в журнал тот же
`FW_DONE_MSG`. Если текст другой — задача через 180 с завершится ошибкой без перезагрузки (прошивка, вероятно, записана и применится
при следующей перезагрузке).
- Ручная проверка UI пользователем на момент коммита не подтверждена.
## Эксплуатация
- Если на устройстве включён `/system/routerboard/settings auto-upgrade=yes`, RouterOS сама записывает встроенную прошивку при загрузке —
состояние «в ROS: X» после отката ROS может не возникнуть.
@@ -38,7 +38,7 @@
| 15 | `refresh=1` остаётся в адресе «Бэкапов» | Низкий | ❌ Открыт | `app/ui/routes.py:485` — `refresh_url` |
| 16 | Порт стенда не параметризован | Низкий | ✅ 025 | `"${APP_BIND:-0.0.0.0}:${APP_PORT:-8000}:8000"`; стенд на `0.0.0.0:8001` через `APP_PORT` в `.env`, override-файл не нужен |
| 17 | Откат ROS не проверен на устройстве | Средний | ✅ Подтверждён | См. «Проверка отката на устройстве» |
| 18 | Прошивка RouterBOARD после отката остаётся новее | Низкий | ⏸ Отложен | Не обрабатывается; на `5G-AC-BED` не проявилось — устройство возвращено на 7.24.4 (`fw_current` = `fw_upgrade` = 7.24.4). Актуально при долгой работе на long-term |
| 18 | Прошивка RouterBOARD после отката остаётся новее | Низкий | ✅ 027 | Состояния колонки «Upgrade FW» (`unknown`/`update`/`downgrade`/`current`) и осознанный откат FW до версии, встроенной в установленную ROS, по образцу отката ROS (023) — [план](../changes/027-fw-downgrade/plan.md) · [итог](../changes/027-fw-downgrade/summary.md) |
| 19 | Тест-линтер видит одну ступень транзитивности | Низкий | ❌ Открыт | `tests/test_architecture.py:54–56` — только `level0`/`level1` |
## Проверка отката на устройстве (п. 17)
+12
View File
@@ -165,3 +165,15 @@ def test_ros_state_values():
assert devices.ros_state({"ros_installed": "7.15", "ros_latest": "7.16"}) == "update"
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.23.7"}) == "downgrade" # long-term старше установленной
assert devices.ros_state({"ros_installed": "7.24.4", "ros_latest": "7.24.4"}) == "current"
def test_fw_state_values():
"""Состояния колонки «Upgrade FW» (изменение 027): unknown / update / downgrade / current;
has_fw_update ложно для downgrade — откат прошивки не считается обновлением."""
assert devices.fw_state({}) == "unknown" # CHR или ещё не опрошено
assert devices.fw_state({"fw_current": "7.14"}) == "unknown" # нет upgrade-firmware
assert devices.fw_state({"fw_current": "7.14", "fw_upgrade": "7.15"}) == "update"
assert devices.fw_state({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) == "downgrade" # после отката ROS
assert devices.fw_state({"fw_current": "7.15", "fw_upgrade": "7.15"}) == "current"
assert devices.has_fw_update({"fw_current": "7.14", "fw_upgrade": "7.15"}) is True
assert devices.has_fw_update({"fw_current": "7.24.4", "fw_upgrade": "7.23.7"}) is False
+60
View File
@@ -126,6 +126,66 @@ async def test_run_ros_downgrade_order_and_failures(monkeypatch):
assert j.status == "failed" and calls == ["backup"]
@pytest.mark.asyncio
async def test_fw_downgrade_order_and_failures(monkeypatch):
"""Изменение 027, по образцу test_run_ros_downgrade_order_and_failures: плата новее встроенной в ROS —
upgrade_firmware не пишет прошивку; fw_downgrade с несовпавшей версией — бэкап и запись не вызываются;
с совпавшей — порядок backup → upgrade → reboot. Заглушка после POST .../routerboard/upgrade сразу
отдаёт в /rest/log запись с FW_DONE_MSG, чтобы ожидание завершалось без опроса."""
calls = []
written = {"on": False}
def handler(r: httpx.Request) -> httpx.Response:
if r.url.path == "/rest/system/routerboard/upgrade":
calls.append("upgrade")
written["on"] = True
return httpx.Response(200, json=[])
if r.url.path == "/rest/system/reboot":
calls.append("reboot")
return httpx.Response(200, json=[])
data = {
"/rest/system/routerboard": {"routerboard": "true", "current-firmware": "7.24.4", "upgrade-firmware": "7.23.7"},
"/rest/system/clock": {"date": "2026-09-28", "time": "12:00:00"},
"/rest/system/resource": {"uptime": "1h"},
"/rest/log": [{".id": "*9", "time": "2026-09-28 12:00:00", "message": ros.FW_DONE_MSG}] if written["on"] else [],
}
return httpx.Response(200, json=data.get(r.url.path, []))
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
monkeypatch.setattr(devices, "open_client", lambda conn: ros_client(handler))
# 1) плата (7.24.4) новее встроенной в ROS (7.23.7) — это откат, не обновление: запись не выполняется
async with ros_client(handler) as c:
msg = await ros.upgrade_firmware(c)
assert "upgrade" not in calls and "новее встроенной" in msg and "Откатить прошивку" in msg
async def fake_backup_ok(device_id):
calls.append("backup")
return "Бэкап загружен в S3: k.backup, k.rsc"
monkeypatch.setattr(ops, "run_backup", fake_backup_ok)
# 2) версия в ROS не совпала с подтверждённой -> задача failed, бэкапа и записи нет
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.6"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "failed" and "не совпадает" in j.message and calls == []
# 3) версия совпала -> порядок backup -> upgrade -> reboot
[jid] = await jobs.start_jobs("fw_downgrade", [d.id], {"target_version": "7.23.7"})
await asyncio.sleep(0.2)
j = jobs.get_job(jid)
assert j.status == "done" and calls == ["backup", "upgrade", "reboot"]
def test_batch_fw_downgrade_requires_target_version():
"""Изменение 027: /batch/fw_downgrade — отдельный типизированный эндпоинт, target_version обязателен."""
d = devices.create_device("r1", "10.0.0.1", 443, "admin", "pw")
with TestClient(create_app()) as c:
h = {"Authorization": f"Bearer {API_TOKEN}"}
assert c.post("/api/v1/batch/fw_downgrade", headers=h, json={"device_ids": [d.id]}).status_code == 422
@pytest.mark.asyncio
async def test_downgrade_api_endpoints(monkeypatch):
"""target_version обязателен для одного устройства и группы; /batch/ros_downgrade — отдельный типизированный