891 lines
22 KiB
Python
891 lines
22 KiB
Python
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 FileResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pathlib import Path as FilePath
|
|
from app.qingping.service import QingpingService
|
|
|
|
from app.my_dataclasses import (
|
|
TION_MAC,
|
|
QINGPING_MAC,
|
|
QINGPING_MQTT_HOST,
|
|
QINGPING_MQTT_PORT,
|
|
MIN_FAN_SPEED,
|
|
MAX_FAN_SPEED,
|
|
MIN_TARGET_TEMP,
|
|
MAX_TARGET_TEMP,
|
|
AIR_MODE_OUTSIDE,
|
|
AIR_MODE_RECIRCULATION,
|
|
SCHEDULE_FILE,
|
|
AUTO_CONFIG_FILE,
|
|
)
|
|
|
|
from app.auto import (
|
|
AutoController,
|
|
Co2SpeedPolicy,
|
|
load_auto_config,
|
|
)
|
|
|
|
from app.auto.temperature_policy import TemperaturePolicy
|
|
|
|
from app.tion import (
|
|
TionController,
|
|
TionService,
|
|
)
|
|
|
|
from schedule import (
|
|
ScheduleService,
|
|
ScheduledSettings,
|
|
load_schedule,
|
|
)
|
|
|
|
SCHEDULE_STATE_FILE = (
|
|
FilePath(SCHEDULE_FILE)
|
|
.with_name("schedule_state.json")
|
|
)
|
|
|
|
|
|
|
|
def configure_logging() -> None:
|
|
noisy_loggers = (
|
|
"asyncio",
|
|
"bleak",
|
|
"bleak.backends",
|
|
"bleak.backends.winrt",
|
|
"bleak.backends.winrt.client",
|
|
"tion_btle",
|
|
"tion_btle.tion",
|
|
"tion_btle.s4",
|
|
"tion_btle.light_family",
|
|
)
|
|
|
|
for logger_name in noisy_loggers:
|
|
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
|
|
|
configure_logging()
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Tion
|
|
# ----------------------------------------------------------------------
|
|
|
|
controller = TionController(TION_MAC)
|
|
|
|
service = TionService(
|
|
controller,
|
|
poll_interval=5,
|
|
)
|
|
|
|
qingping_service = QingpingService(
|
|
host=QINGPING_MQTT_HOST,
|
|
port=QINGPING_MQTT_PORT,
|
|
mac=QINGPING_MAC,
|
|
)
|
|
|
|
schedule_service: ScheduleService | None = None
|
|
schedule_load_error: str | None = None
|
|
|
|
auto_controller: AutoController | None = None
|
|
auto_load_error: str | None = None
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Application lifecycle
|
|
# ----------------------------------------------------------------------
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global schedule_service
|
|
global schedule_load_error
|
|
global auto_controller
|
|
global auto_load_error
|
|
|
|
await service.start()
|
|
await qingping_service.start()
|
|
|
|
try:
|
|
try:
|
|
config = load_schedule(SCHEDULE_FILE)
|
|
|
|
schedule_service = ScheduleService(
|
|
config,
|
|
service,
|
|
check_interval=5,
|
|
state_path=SCHEDULE_STATE_FILE,
|
|
)
|
|
|
|
await schedule_service.start()
|
|
schedule_load_error = None
|
|
|
|
try:
|
|
auto_config = load_auto_config(AUTO_CONFIG_FILE)
|
|
|
|
auto_policy = Co2SpeedPolicy(
|
|
base_speed=(
|
|
auto_config.co2.base_speed
|
|
),
|
|
thresholds=(
|
|
auto_config.co2.thresholds
|
|
),
|
|
hysteresis=(
|
|
auto_config.co2.hysteresis
|
|
),
|
|
)
|
|
|
|
temperature_policy = TemperaturePolicy(auto_config.temperature)
|
|
|
|
auto_interval = (auto_config.check_interval)
|
|
|
|
auto_load_error = None
|
|
|
|
except Exception as exc:
|
|
auto_policy = None
|
|
temperature_policy = None
|
|
|
|
# Безопасный встроенный интервал нужен,
|
|
# потому что auto.yaml сейчас недоступен.
|
|
auto_interval = 5.0
|
|
|
|
auto_load_error = (
|
|
f"{type(exc).__name__}: {exc}"
|
|
)
|
|
|
|
auto_controller = AutoController(
|
|
schedule_service=schedule_service,
|
|
qingping_service=qingping_service,
|
|
tion_service=service,
|
|
policy=auto_policy,
|
|
temperature_policy=temperature_policy,
|
|
interval=auto_interval,
|
|
)
|
|
|
|
await auto_controller.start()
|
|
|
|
except Exception as exc:
|
|
schedule_service = None
|
|
schedule_load_error = f"{type(exc).__name__}: {exc}"
|
|
yield
|
|
|
|
finally:
|
|
if schedule_service is not None:
|
|
await schedule_service.stop()
|
|
|
|
await qingping_service.stop()
|
|
await service.stop()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# FastAPI
|
|
# ----------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="Tion Controller",
|
|
version="0.1.0",
|
|
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(
|
|
request: Request,
|
|
exc: RequestValidationError,
|
|
):
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"message": "Invalid request",
|
|
"errors": exc.errors(),
|
|
},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Helpers
|
|
# ----------------------------------------------------------------------
|
|
|
|
def get_status() -> dict:
|
|
"""
|
|
Получить текущее состояние сервиса.
|
|
|
|
Bluetooth-запрос здесь не выполняется.
|
|
"""
|
|
|
|
state = service.state
|
|
|
|
return {
|
|
"online": service.online,
|
|
"running": service.running,
|
|
|
|
"last_seen": (
|
|
service.last_seen.isoformat()
|
|
if service.last_seen is not None
|
|
else None
|
|
),
|
|
|
|
"last_error": service.last_error,
|
|
|
|
"tion": (
|
|
state.to_dict()
|
|
if state is not None
|
|
else None
|
|
),
|
|
"auto": get_auto_status(),
|
|
|
|
"qingping": qingping_service.status(),
|
|
|
|
"schedule": get_schedule_status(),
|
|
}
|
|
|
|
def get_effective_speed() -> int:
|
|
|
|
if (
|
|
schedule_service is not None
|
|
and schedule_service.override_active
|
|
and schedule_service.override_settings.speed is not None
|
|
):
|
|
return schedule_service.override_settings.speed
|
|
|
|
state = service.state
|
|
|
|
if state is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail={
|
|
"message": "Tion state unavailable",
|
|
},
|
|
)
|
|
|
|
return state.fan_speed
|
|
|
|
def get_effective_temperature() -> int:
|
|
|
|
if (
|
|
schedule_service is not None
|
|
and schedule_service.override_active
|
|
and schedule_service.override_settings.target_temp is not None
|
|
):
|
|
return (
|
|
schedule_service
|
|
.override_settings
|
|
.target_temp
|
|
)
|
|
|
|
state = service.state
|
|
|
|
if state is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail={
|
|
"message": "Tion state unavailable",
|
|
},
|
|
)
|
|
|
|
return state.target_temp
|
|
|
|
async def execute_command(operation, override: ScheduledSettings | None = None):
|
|
|
|
try:
|
|
# При активном расписании ручная команда
|
|
# становится temporary override.
|
|
#
|
|
# Но если расписание paused,
|
|
# мы находимся в полноценном ручном режиме,
|
|
# поэтому команда идёт напрямую в Tion.
|
|
if (
|
|
override is not None
|
|
and schedule_service is not None
|
|
and schedule_service.running
|
|
and schedule_service.config.enabled
|
|
and not schedule_service.paused
|
|
):
|
|
await schedule_service.apply_override(
|
|
override
|
|
)
|
|
|
|
else:
|
|
await service.execute(
|
|
operation
|
|
)
|
|
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail={
|
|
"message": "Tion unavailable",
|
|
"error": (
|
|
f"{type(exc).__name__}: {exc}"
|
|
),
|
|
},
|
|
) from exc
|
|
|
|
return get_status()
|
|
|
|
|
|
def occurrence_to_dict(occurrence) -> dict | None:
|
|
|
|
if occurrence is None:
|
|
return None
|
|
|
|
return {
|
|
"when": occurrence.when.isoformat(),
|
|
"weekday": occurrence.weekday,
|
|
"template": occurrence.template,
|
|
"action": occurrence.point.action.type.value,
|
|
"settings": (
|
|
occurrence
|
|
.point
|
|
.action
|
|
.settings
|
|
.to_dict()
|
|
),
|
|
}
|
|
|
|
|
|
def get_schedule_status() -> dict:
|
|
|
|
if schedule_service is None:
|
|
return {
|
|
"auto_active": False,
|
|
"auto_fallback_speed": None,
|
|
"auto_target_temp": None,
|
|
"available": False,
|
|
"current_action": None,
|
|
"current": None,
|
|
"enabled": False,
|
|
"next": None,
|
|
"override_active": False,
|
|
"override_until": None,
|
|
"override_settings": {},
|
|
"paused": False,
|
|
"running": False,
|
|
"scheduled_settings": {},
|
|
"last_error": schedule_load_error,
|
|
}
|
|
|
|
resolution = schedule_service.resolve()
|
|
|
|
current = resolution.current
|
|
|
|
current_time = (
|
|
current.when.strftime("%H:%M")
|
|
if current is not None
|
|
else None
|
|
)
|
|
|
|
next_time = (
|
|
resolution.next.when.strftime("%H:%M")
|
|
if resolution.next is not None
|
|
else None
|
|
)
|
|
|
|
next_action = (
|
|
resolution.next.point.action.type.value
|
|
if resolution.next is not None
|
|
else None
|
|
)
|
|
|
|
override_until_time = (
|
|
schedule_service.override_until.strftime("%H:%M")
|
|
if schedule_service.override_until
|
|
else None
|
|
)
|
|
|
|
current_action = None
|
|
|
|
if current is not None:
|
|
current_action = (current.point.action.type.value)
|
|
|
|
return {
|
|
"available": True,
|
|
"enabled": resolution.enabled,
|
|
"running": schedule_service.running,
|
|
|
|
"paused": schedule_service.paused,
|
|
|
|
"current_action": current_action,
|
|
"current_time": current_time,
|
|
|
|
"next_time": next_time,
|
|
"next_action": next_action,
|
|
|
|
"override_until_time": override_until_time,
|
|
|
|
"current": occurrence_to_dict(resolution.current),
|
|
"next": occurrence_to_dict(resolution.next),
|
|
|
|
"auto_active": resolution.auto_active,
|
|
|
|
"auto_fallback_speed": resolution.auto_fallback_speed,
|
|
|
|
"auto_target_temp": resolution.auto_target_temp,
|
|
|
|
"scheduled_settings": resolution.scheduled_settings.to_dict(),
|
|
|
|
"override_active":schedule_service.override_active,
|
|
|
|
"override_until": (
|
|
schedule_service
|
|
.override_until
|
|
.isoformat()
|
|
if schedule_service.override_until
|
|
else None
|
|
),
|
|
|
|
"override_settings": (
|
|
schedule_service
|
|
.override_settings
|
|
.to_dict()
|
|
),
|
|
|
|
"last_error": (
|
|
schedule_service.last_error
|
|
),
|
|
}
|
|
|
|
|
|
def get_auto_status() -> dict:
|
|
|
|
if auto_controller is None:
|
|
return {
|
|
"available": False,
|
|
"state": "unavailable",
|
|
"reason": None,
|
|
"target_speed": None,
|
|
"auto_speed": None,
|
|
"last_error": None,
|
|
"config_error": auto_load_error,
|
|
}
|
|
|
|
return {
|
|
"available": True,
|
|
**auto_controller.status(),
|
|
"config_error": (
|
|
auto_load_error
|
|
),
|
|
}
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Status
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.get("/api/status")
|
|
async def status():
|
|
return get_status()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Power
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/power/{state}")
|
|
async def set_power(state: Literal["on", "off"]):
|
|
|
|
enabled = state == "on"
|
|
|
|
if enabled:
|
|
operation = lambda tion: tion.power_on()
|
|
else:
|
|
operation = lambda tion: tion.power_off()
|
|
|
|
return await execute_command(
|
|
operation,
|
|
ScheduledSettings(power=enabled),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Fan speed increase / decrease
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/speed/increase")
|
|
async def increase_speed():
|
|
|
|
current_speed = get_effective_speed()
|
|
|
|
new_speed = min(current_speed + 1, MAX_FAN_SPEED)
|
|
|
|
if new_speed == current_speed:
|
|
return get_status()
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_speed(new_speed),
|
|
|
|
ScheduledSettings(
|
|
speed=new_speed,
|
|
),
|
|
)
|
|
|
|
|
|
@app.post("/api/tion/speed/decrease")
|
|
async def decrease_speed():
|
|
|
|
current_speed = get_effective_speed()
|
|
|
|
new_speed = max(current_speed - 1, MIN_FAN_SPEED)
|
|
|
|
if new_speed == current_speed:
|
|
return get_status()
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_speed(new_speed),
|
|
|
|
ScheduledSettings(
|
|
speed=new_speed,
|
|
),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Fan speed
|
|
# ----------------------------------------------------------------------
|
|
@app.post("/api/tion/speed/{speed}")
|
|
async def set_speed(
|
|
speed: Annotated[
|
|
int,
|
|
Path(
|
|
ge=MIN_FAN_SPEED,
|
|
le=MAX_FAN_SPEED,
|
|
),
|
|
] ):
|
|
|
|
return await execute_command(
|
|
lambda tion: tion.set_speed(speed),
|
|
ScheduledSettings(speed=speed),
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Heater
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/heater/{state}")
|
|
async def set_heater(state: Literal["on", "off"]):
|
|
|
|
enabled = state == "on"
|
|
|
|
if enabled:
|
|
operation = lambda tion: tion.heater_on()
|
|
else:
|
|
operation = lambda tion: tion.heater_off()
|
|
|
|
|
|
return await execute_command(
|
|
operation,
|
|
ScheduledSettings(heater=enabled),
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Target temperature increase / decrease
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/temperature/increase")
|
|
async def increase_temperature():
|
|
|
|
current_temperature = get_effective_temperature()
|
|
|
|
new_temperature = min(current_temperature + 1, MAX_TARGET_TEMP)
|
|
|
|
if new_temperature == current_temperature:
|
|
return get_status()
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_target_temperature(
|
|
new_temperature
|
|
),
|
|
|
|
ScheduledSettings(
|
|
target_temp=new_temperature,
|
|
),
|
|
)
|
|
|
|
|
|
@app.post("/api/tion/temperature/decrease")
|
|
async def decrease_temperature():
|
|
|
|
current_temperature = get_effective_temperature()
|
|
|
|
new_temperature = max(current_temperature - 1, MIN_TARGET_TEMP)
|
|
|
|
if new_temperature == current_temperature:
|
|
return get_status()
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_target_temperature(
|
|
new_temperature
|
|
),
|
|
|
|
ScheduledSettings(
|
|
target_temp=new_temperature,
|
|
),
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Target temperature
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/temperature/{temperature}")
|
|
|
|
async def set_temperature(
|
|
temperature: Annotated[
|
|
int,
|
|
Path(
|
|
ge=MIN_TARGET_TEMP,
|
|
le=MAX_TARGET_TEMP,
|
|
),
|
|
],):
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_target_temperature(temperature),
|
|
ScheduledSettings(target_temp=temperature),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Air mode
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/mode/{mode}")
|
|
async def set_mode(
|
|
mode: str,
|
|
):
|
|
if mode not in (
|
|
AIR_MODE_OUTSIDE,
|
|
AIR_MODE_RECIRCULATION,
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail={
|
|
"message": "Invalid air mode",
|
|
"allowed": [
|
|
AIR_MODE_OUTSIDE,
|
|
AIR_MODE_RECIRCULATION,
|
|
],
|
|
},
|
|
)
|
|
|
|
return await execute_command(
|
|
lambda tion:
|
|
tion.set_air_mode(mode),
|
|
|
|
ScheduledSettings(
|
|
mode=mode,
|
|
),
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Sound
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/sound/{value}")
|
|
async def set_sound(
|
|
value: Literal["on", "off"],
|
|
):
|
|
enabled = value == "on"
|
|
|
|
if enabled:
|
|
operation = lambda tion: tion.sound_on()
|
|
else:
|
|
operation = lambda tion: tion.sound_off()
|
|
|
|
return await execute_command(
|
|
operation,
|
|
ScheduledSettings(
|
|
sound=enabled,
|
|
),
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Light
|
|
# ----------------------------------------------------------------------
|
|
|
|
@app.post("/api/tion/light/{value}")
|
|
async def set_light(
|
|
value: Literal["on", "off"],
|
|
):
|
|
enabled = value == "on"
|
|
|
|
if enabled:
|
|
operation = lambda tion: tion.light_on()
|
|
else:
|
|
operation = lambda tion: tion.light_off()
|
|
|
|
return await execute_command(
|
|
operation,
|
|
ScheduledSettings(
|
|
light=enabled,
|
|
),
|
|
)
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Переход в 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:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Schedule service unavailable",
|
|
)
|
|
|
|
await schedule_service.clear_override()
|
|
|
|
return {
|
|
"ok": True,
|
|
"schedule": get_schedule_status(),
|
|
"auto": get_auto_status(),
|
|
}
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Переход в полностью manual
|
|
# ----------------------------------------------------------------------
|
|
@app.post("/api/schedule/pause")
|
|
async def pause_schedule():
|
|
|
|
if schedule_service is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Schedule service unavailable",
|
|
)
|
|
|
|
await schedule_service.pause()
|
|
|
|
return {
|
|
"ok": True,
|
|
"schedule": get_schedule_status(),
|
|
}
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Возвращаем AUTO
|
|
# ----------------------------------------------------------------------
|
|
@app.post("/api/schedule/resume")
|
|
async def resume_schedule():
|
|
|
|
if schedule_service is None:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Schedule service unavailable",
|
|
)
|
|
|
|
await schedule_service.resume()
|
|
|
|
return {
|
|
"ok": True,
|
|
"schedule": get_schedule_status(),
|
|
}
|