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

This commit is contained in:
Fedorov Dmitriy
2026-09-20 01:59:36 +03:00
parent 65cf547c6c
commit 0172b6bcf2
4 changed files with 155 additions and 15 deletions
+113 -2
View File
@@ -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(),
}
}
+20 -11
View File
@@ -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,
)
-1
View File
@@ -3,4 +3,3 @@ fastapi
uvicorn[standard]
PyYAML
paho-mqtt>=2.1,<3
paho-mqtt
+22 -1
View File
@@ -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()
)
)