feat: v.1.2.0 добавлин web интерфейс.
This commit is contained in:
+113
-2
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -3,4 +3,3 @@ fastapi
|
||||
uvicorn[standard]
|
||||
PyYAML
|
||||
paho-mqtt>=2.1,<3
|
||||
paho-mqtt
|
||||
+22
-1
@@ -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()
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user