"""Операции над устройством через RouterOS REST. Не знают про БД и S3.""" import asyncio import base64 import re from datetime import datetime, timedelta from pathlib import Path from app.ros.client import RosClient, RosError CHANNELS = ("stable", "long-term", "testing", "development") async def get_status(c: RosClient, check_updates: bool = True) -> dict: """Собирает статус: модель, канал, версии ROS/FW, uptime и доступные обновления. latest-version в system/package/update появляется только после check-for-updates (устройство обращается к серверам MikroTik). Сбой проверки не считается сбоем опроса. """ resource, board, update = await asyncio.gather( c.get("system/resource"), c.get("system/routerboard"), c.get("system/package/update"), ) check_error = None if check_updates: try: await c.post("system/package/update/check-for-updates", timeout=30) update = await c.get("system/package/update") except RosError as e: check_error = str(e) is_board = str(board.get("routerboard", "false")) == "true" return { "model": (board.get("model") if is_board else None) or resource.get("board-name"), "channel": update.get("channel"), "ros_version": resource.get("version"), "ros_installed": update.get("installed-version"), # без суффикса «(stable)» — для сравнения "ros_latest": update.get("latest-version"), "ros_update_status": update.get("status"), "ros_check_error": check_error, "fw_current": board.get("current-firmware") if is_board else None, "fw_upgrade": board.get("upgrade-firmware") if is_board else None, "uptime": resource.get("uptime"), } async def wait_file(c: RosClient, name: str, attempts: int = 15) -> str: """Ждёт появления файла на устройстве и возвращает его .id.""" for _ in range(attempts): files = await c.get("file", name=name) if files: return files[0][".id"] await asyncio.sleep(1) raise RosError(f"Файл {name} не появился на устройстве") async def create_backup_files(c: RosClient, base: str) -> tuple[str, str]: """Создаёт бинарный бэкап (без шифрования) и .rsc-экспорт (с секретами). Возвращает имена файлов.""" binary, rsc = f"{base}.backup", f"{base}.rsc" await c.post("system/backup/save", {"name": base, "dont-encrypt": "yes"}) # show-sensitive: в .rsc попадают пароли, ключи и секреты — из файла можно восстановить всё, что их использует await c.post("export", {"file": base, "show-sensitive": ""}) await wait_file(c, binary) await wait_file(c, rsc) return binary, rsc async def download_file(c: RosClient, name: str, dest: Path, chunk_size: int = 32768) -> int: """Скачивает файл блоками через REST (/file/read). REST не отдаёт содержимое больших файлов через GET /file, а бинарные данные в JSON ненадёжны, поэтому блок читается скриптом и передаётся в base64. Возвращает размер файла; сверяет его с размером на устройстве.""" files = await c.get("file", name=name) if not files: raise RosError(f"Файл {name} не найден на устройстве") size = int(files[0]["size"]) received = 0 with dest.open("wb") as f: while received < size: script = (f':put [:convert to=base64 ([/file/read file="{name}" offset={received} ' f'chunk-size={chunk_size} as-value]->"data")]') res = await c.post("execute", {"script": script, "as-string": ""}) chunk = base64.b64decode((res or {}).get("ret", "")) if not chunk: raise RosError(f"Пустой блок при чтении {name} со смещения {received}") f.write(chunk) received += len(chunk) if received != size: raise RosError(f"Размер {name}: получено {received} из {size} байт") return size async def remove_file(c: RosClient, filename: str) -> None: files = await c.get("file", name=filename) for f in files or []: await c.delete(f"file/{f['.id']}") async def set_channel(c: RosClient, channel: str) -> None: if channel not in CHANNELS: raise ValueError(f"Неизвестный канал: {channel}") await c.post("system/package/update/set", {"channel": channel}) async def install_ros_update(c: RosClient) -> str: """Проверяет обновления и запускает установку. Устройство само перезагрузится после скачивания.""" await c.post("system/package/update/check-for-updates", timeout=120) u = await c.get("system/package/update") installed, latest = u.get("installed-version"), u.get("latest-version") if not latest or installed == latest: return f"Обновление не требуется (версия {installed})" try: await c.post("system/package/update/install", timeout=15) except RosError as e: # после скачивания устройство уходит в перезагрузку и рвёт соединение — это штатно if e.status is not None: raise return f"Обновление {installed} → {latest} запущено, устройство перезагрузится" FW_DONE_MSG = "Firmware upgraded successfully, please reboot for changes to take effect!" _UPTIME_RE = re.compile(r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$") def parse_uptime(value: str | None) -> int | None: """'1w2d3h4m5s' -> секунды; None, если формат не распознан.""" m = _UPTIME_RE.match(value or "") if not m or not any(m.groups()): return None w, d, h, mi, sec = (int(x or 0) for x in m.groups()) return (((w * 7 + d) * 24 + h) * 60 + mi) * 60 + sec def _ts(value: str | None) -> datetime | None: try: return datetime.strptime(value or "", "%Y-%m-%d %H:%M:%S") except ValueError: return None async def _fw_done_entries(c: RosClient) -> list[dict]: # фильтр REST ?message=… по точному тексту ничего не возвращает — фильтруем сами log = await c.get("log", **{".proplist": ".id,time,message"}) return [e for e in log or [] if e.get("message") == FW_DONE_MSG] async def _booted_at(c: RosClient) -> datetime | None: """Время последней загрузки устройства по его часам (в той же зоне, что и время журнала).""" clock, res = await asyncio.gather(c.get("system/clock"), c.get("system/resource")) now = _ts(f"{clock.get('date')} {clock.get('time')}") up = parse_uptime(res.get("uptime")) 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 c.get("system/routerboard") 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')})" 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): # прошивка уже записана после последней загрузки, ждём только перезагрузки await _reboot(c) return "Прошивка уже записана (запись в журнале), перезагрузка отправлена" known = {e[".id"] for e in before} await c.post("system/routerboard/upgrade") 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')}, перезагрузка отправлена" await asyncio.sleep(poll) raise RosError("В журнале не появилась запись об обновлении прошивки за отведённое время") async def _reboot(c: RosClient) -> None: try: await c.post("system/reboot", timeout=10) except RosError as e: if e.status is not None: # обрыв соединения при перезагрузке — штатно raise