From 8f749da14c1c428f93aef93697cf503d711a0369 Mon Sep 17 00:00:00 2001 From: Fedorov Dmitriy Date: Sun, 20 Sep 2026 02:00:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20v.1.2.0=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B8=D0=BD=20web=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D1=84=D0=B5=D0=B9=D1=81.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api.py | 115 +++++++++++++++++++++++++++++++++++++++++++- main.py | 31 +++++++----- requirements.txt | 1 - schedule/service.py | 23 ++++++++- 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/app/api.py b/app/api.py index 5f67b0f..1a10fd7 100644 --- a/app/api.py +++ b/app/api.py @@ -1,9 +1,12 @@ import logging +import shutil from contextlib import asynccontextmanager from typing import Literal, Annotated +import yaml from fastapi import FastAPI, Request, HTTPException, Path from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.staticfiles import StaticFiles from pathlib import Path as FilePath from app.qingping.service import QingpingService @@ -184,6 +187,34 @@ app = FastAPI( lifespan=lifespan, ) +WEB_ROOT = FilePath(__file__).resolve().parents[1] / "web" + +app.mount( + "/ui/static", + StaticFiles(directory=WEB_ROOT), + name="ui-static", +) + + +@app.get("/", include_in_schema=False) +async def root(): + return RedirectResponse(url="/ui/panel") + + +@app.get("/ui", include_in_schema=False) +async def ui_root(): + return RedirectResponse(url="/ui/panel") + + +@app.get("/ui/widget", include_in_schema=False) +async def ui_widget(): + return FileResponse(WEB_ROOT / "widget.html") + + +@app.get("/ui/panel", include_in_schema=False) +async def ui_panel(): + return FileResponse(WEB_ROOT / "panel.html") + @app.exception_handler(RequestValidationError) async def validation_exception_handler( @@ -724,6 +755,86 @@ async def set_light( # ---------------------------------------------------------------------- # Переход в auto # ---------------------------------------------------------------------- + +@app.get("/api/schedule/config") +async def get_schedule_config(): + try: + raw = yaml.safe_load( + FilePath(SCHEDULE_FILE).read_text(encoding="utf-8") + ) + except (OSError, yaml.YAMLError) as exc: + raise HTTPException( + status_code=500, + detail={ + "message": "Could not read schedule config", + "error": f"{type(exc).__name__}: {exc}", + }, + ) from exc + + if not isinstance(raw, dict): + raise HTTPException( + status_code=500, + detail="Schedule config root must be an object", + ) + + return raw + + +@app.put("/api/schedule/config") +async def update_schedule_config(payload: dict): + global schedule_load_error + + schedule_path = FilePath(SCHEDULE_FILE) + temp_path = schedule_path.with_name(schedule_path.name + ".tmp") + backup_path = schedule_path.with_name(schedule_path.name + ".bak") + + try: + serialized = yaml.safe_dump( + payload, + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + ) + temp_path.write_text(serialized, encoding="utf-8") + config = load_schedule(temp_path) + except (OSError, ValueError, yaml.YAMLError) as exc: + temp_path.unlink(missing_ok=True) + raise HTTPException( + status_code=400, + detail={ + "message": "Invalid schedule config", + "error": str(exc), + }, + ) from exc + + try: + if schedule_path.exists(): + shutil.copy2(schedule_path, backup_path) + temp_path.replace(schedule_path) + except OSError as exc: + temp_path.unlink(missing_ok=True) + raise HTTPException( + status_code=500, + detail={ + "message": "Could not save schedule config", + "error": f"{type(exc).__name__}: {exc}", + }, + ) from exc + + schedule_load_error = None + restart_required = schedule_service is None + + if schedule_service is not None: + await schedule_service.replace_config(config) + + return { + "ok": True, + "restart_required": restart_required, + "config": payload, + "schedule": get_schedule_status(), + } + + @app.post("/api/schedule/override/clear") async def clear_schedule_override(): if schedule_service is None: @@ -776,4 +887,4 @@ async def resume_schedule(): return { "ok": True, "schedule": get_schedule_status(), - } \ No newline at end of file + } diff --git a/main.py b/main.py index f167671..c3ccbdf 100644 --- a/main.py +++ b/main.py @@ -1,16 +1,25 @@ -# Это пример Python скрипта. +import argparse -# Нажмите Shift+F10 для выполнения или замените его своим кодом. -# Нажмите Двойное нажатие Shift для поиска везде: классы, файлы, окна инструментов, действия и настройки. +import uvicorn -def print_hi(name): - # Используйте точку останова в строке кода ниже для отладки скрипта. - print(f'Hi, {name}') # Нажмите Ctrl+F8 для переключения точки останова. +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Tion Controller web server") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", default=8000, type=int) + parser.add_argument( + "--reload", + action="store_true", + help="Reload the server after source file changes", + ) + return parser.parse_args() -# Нажмите зеленую кнопку на полях для запуска скрипта. -if __name__ == '__main__': - print_hi('PyCharm') - -# Справка PyCharm доступна на https://www.jetbrains.com/help/pycharm/ +if __name__ == "__main__": + args = parse_args() + uvicorn.run( + "app.api:app", + host=args.host, + port=args.port, + reload=args.reload, + ) diff --git a/requirements.txt b/requirements.txt index e6d0489..f15b8cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,3 @@ fastapi uvicorn[standard] PyYAML paho-mqtt>=2.1,<3 -paho-mqtt \ No newline at end of file diff --git a/schedule/service.py b/schedule/service.py index ad4d226..c63bbf4 100644 --- a/schedule/service.py +++ b/schedule/service.py @@ -105,6 +105,27 @@ class ScheduleService: def config(self) -> ScheduleConfig: return self._config + async def replace_config( + self, + config: ScheduleConfig, + ) -> None: + """Заменить расписание без перезапуска приложения.""" + if not isinstance(config, ScheduleConfig): + raise TypeError("config must be ScheduleConfig") + + self._config = config + self._last_applied_when = None + self._clear_override() + self._last_error = None + + # Сохранение расписания не должно завершаться ошибкой только из-за + # временно недоступного Bluetooth. Фоновый цикл повторит применение. + if self._running and not self._paused: + try: + await self._process() + except Exception as exc: + self._last_error = f"{type(exc).__name__}: {exc}" + def _load_runtime_state(self) -> None: if self._state_path is None: @@ -626,4 +647,4 @@ class ScheduleService: if settings.power is False: await self._tion.execute( lambda tion: tion.power_off() - ) \ No newline at end of file + )