feat: v.1.2.0 добавлин web интерфейс.

This commit is contained in:
Fedorov Dmitriy
2026-09-20 02:00:28 +03:00
parent 3cd71ba5ff
commit 8f749da14c
4 changed files with 155 additions and 15 deletions
+113 -2
View File
@@ -1,9 +1,12 @@
import logging import logging
import shutil
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Literal, Annotated from typing import Literal, Annotated
import yaml
from fastapi import FastAPI, Request, HTTPException, Path from fastapi import FastAPI, Request, HTTPException, Path
from fastapi.exceptions import RequestValidationError 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 pathlib import Path as FilePath
from app.qingping.service import QingpingService from app.qingping.service import QingpingService
@@ -184,6 +187,34 @@ app = FastAPI(
lifespan=lifespan, 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) @app.exception_handler(RequestValidationError)
async def validation_exception_handler( async def validation_exception_handler(
@@ -724,6 +755,86 @@ async def set_light(
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Переход в auto # Переход в 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") @app.post("/api/schedule/override/clear")
async def clear_schedule_override(): async def clear_schedule_override():
if schedule_service is None: if schedule_service is None:
@@ -776,4 +887,4 @@ async def resume_schedule():
return { return {
"ok": True, "ok": True,
"schedule": get_schedule_status(), "schedule": get_schedule_status(),
} }
+20 -11
View File
@@ -1,16 +1,25 @@
# Это пример Python скрипта. import argparse
# Нажмите Shift+F10 для выполнения или замените его своим кодом. import uvicorn
# Нажмите Двойное нажатие Shift для поиска везде: классы, файлы, окна инструментов, действия и настройки.
def print_hi(name): def parse_args() -> argparse.Namespace:
# Используйте точку останова в строке кода ниже для отладки скрипта. parser = argparse.ArgumentParser(description="Tion Controller web server")
print(f'Hi, {name}') # Нажмите Ctrl+F8 для переключения точки останова. 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__":
if __name__ == '__main__': args = parse_args()
print_hi('PyCharm') uvicorn.run(
"app.api:app",
# Справка PyCharm доступна на https://www.jetbrains.com/help/pycharm/ host=args.host,
port=args.port,
reload=args.reload,
)
-1
View File
@@ -3,4 +3,3 @@ fastapi
uvicorn[standard] uvicorn[standard]
PyYAML PyYAML
paho-mqtt>=2.1,<3 paho-mqtt>=2.1,<3
paho-mqtt
+22 -1
View File
@@ -105,6 +105,27 @@ class ScheduleService:
def config(self) -> ScheduleConfig: def config(self) -> ScheduleConfig:
return self._config 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: def _load_runtime_state(self) -> None:
if self._state_path is None: if self._state_path is None:
@@ -626,4 +647,4 @@ class ScheduleService:
if settings.power is False: if settings.power is False:
await self._tion.execute( await self._tion.execute(
lambda tion: tion.power_off() lambda tion: tion.power_off()
) )