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