Compare commits
26
Commits
master
...
fdb798a430
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb798a430 | ||
|
|
33cdb205b3 | ||
|
|
83d2eb619c | ||
|
|
8069ab65f2 | ||
|
|
455d191ea1 | ||
|
|
8f749da14c | ||
|
|
0172b6bcf2 | ||
|
|
65cf547c6c | ||
|
|
3cd71ba5ff | ||
|
|
741be479a6 | ||
|
|
958691a481 | ||
|
|
a5c9d07db5 | ||
|
|
c22e8bc71d | ||
|
|
b02d18d283 | ||
|
|
c6d92c10ee | ||
|
|
bff8efb85b | ||
|
|
f98cc2d6a3 | ||
|
|
cdec9b0996 | ||
|
|
7dbfd8b069 | ||
|
|
a3dafb4f59 | ||
|
|
dec14b1d12 | ||
|
|
4c665d9108 | ||
|
|
419b8efe33 | ||
|
|
fdae9dec72 | ||
|
|
5cc125db3f | ||
|
|
9241da63c3 |
Generated
+1
@@ -2,6 +2,7 @@
|
|||||||
<module type="PYTHON_MODULE" version="4">
|
<module type="PYTHON_MODULE" version="4">
|
||||||
<component name="NewModuleRootManager">
|
<component name="NewModuleRootManager">
|
||||||
<content url="file://$MODULE_DIR$">
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[version]
|
||||||
|
major = 1
|
||||||
|
minor = 2
|
||||||
|
patch = 1
|
||||||
|
|
||||||
|
[build]
|
||||||
|
date = "2026-09-20"
|
||||||
|
time = "11:28:15"
|
||||||
+890
@@ -0,0 +1,890 @@
|
|||||||
|
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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from .config import (
|
||||||
|
AutoConfig,
|
||||||
|
Co2Config,
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .co2_policy import (
|
||||||
|
Co2SpeedPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AutoConfig",
|
||||||
|
"Co2Config",
|
||||||
|
"load_auto_config",
|
||||||
|
"Co2SpeedPolicy",
|
||||||
|
"AutoController",
|
||||||
|
]
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
class Co2SpeedPolicy:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_speed: int,
|
||||||
|
thresholds: tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
...
|
||||||
|
],
|
||||||
|
hysteresis: int,
|
||||||
|
):
|
||||||
|
if hysteresis < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"hysteresis must be >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not thresholds:
|
||||||
|
raise ValueError(
|
||||||
|
"thresholds cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_ppm = None
|
||||||
|
|
||||||
|
speeds = [base_speed]
|
||||||
|
|
||||||
|
for ppm, speed in thresholds:
|
||||||
|
|
||||||
|
if previous_ppm is not None:
|
||||||
|
if ppm <= previous_ppm:
|
||||||
|
raise ValueError(
|
||||||
|
"CO2 thresholds must "
|
||||||
|
"be strictly increasing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if speed in speeds:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO speeds must be unique"
|
||||||
|
)
|
||||||
|
|
||||||
|
speeds.append(speed)
|
||||||
|
previous_ppm = ppm
|
||||||
|
|
||||||
|
self._base_speed = base_speed
|
||||||
|
self._thresholds = thresholds
|
||||||
|
self._hysteresis = hysteresis
|
||||||
|
self._speeds = tuple(speeds)
|
||||||
|
|
||||||
|
|
||||||
|
def select_speed(self, co2: int, current_speed: int | None) -> int:
|
||||||
|
|
||||||
|
if co2 < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"CO2 cannot be negative"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первое решение AUTO.
|
||||||
|
#
|
||||||
|
# Гистерезис пока применять не к чему:
|
||||||
|
# предыдущей AUTO-скорости ещё нет.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
current_speed is None
|
||||||
|
or current_speed not in self._speeds
|
||||||
|
):
|
||||||
|
return self._select_initial_speed(co2)
|
||||||
|
|
||||||
|
index = self._speeds.index(current_speed)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# CO2 растёт.
|
||||||
|
#
|
||||||
|
# Проверяем пороги перехода вверх.
|
||||||
|
# За один вызов можем перепрыгнуть
|
||||||
|
# сразу несколько скоростей.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
while index < len(
|
||||||
|
self._thresholds
|
||||||
|
):
|
||||||
|
|
||||||
|
ppm, _ = self._thresholds[index]
|
||||||
|
|
||||||
|
if co2 < ppm:
|
||||||
|
break
|
||||||
|
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# CO2 падает.
|
||||||
|
#
|
||||||
|
# Для перехода вниз используем:
|
||||||
|
#
|
||||||
|
# threshold - hysteresis
|
||||||
|
#
|
||||||
|
# Поэтому скорость не будет прыгать
|
||||||
|
# туда-сюда около одного порога.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
while index > 0:
|
||||||
|
|
||||||
|
ppm, _ = self._thresholds[index - 1]
|
||||||
|
|
||||||
|
down_threshold = ppm - self._hysteresis
|
||||||
|
|
||||||
|
if co2 > down_threshold:
|
||||||
|
break
|
||||||
|
|
||||||
|
index -= 1
|
||||||
|
|
||||||
|
return self._speeds[index]
|
||||||
|
|
||||||
|
|
||||||
|
def _select_initial_speed(self, co2: int) -> int:
|
||||||
|
|
||||||
|
speed = self._base_speed
|
||||||
|
|
||||||
|
for ppm, candidate_speed in (
|
||||||
|
self._thresholds
|
||||||
|
):
|
||||||
|
|
||||||
|
if co2 < ppm:
|
||||||
|
break
|
||||||
|
|
||||||
|
speed = candidate_speed
|
||||||
|
|
||||||
|
return speed
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
MIN_FAN_SPEED,
|
||||||
|
MAX_FAN_SPEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class Co2Config:
|
||||||
|
base_speed: int
|
||||||
|
hysteresis: int
|
||||||
|
|
||||||
|
thresholds: tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
...
|
||||||
|
]
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class TemperatureConfig:
|
||||||
|
hysteresis: float
|
||||||
|
|
||||||
|
@dataclass(
|
||||||
|
frozen=True,
|
||||||
|
slots=True,
|
||||||
|
)
|
||||||
|
class AutoConfig:
|
||||||
|
version: int
|
||||||
|
check_interval: float
|
||||||
|
co2: Co2Config
|
||||||
|
temperature: TemperatureConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _require_dict(name: str, value: Any) -> dict:
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_speed(name: str, value: Any) -> int:
|
||||||
|
|
||||||
|
if type(value) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
MIN_FAN_SPEED
|
||||||
|
<= value
|
||||||
|
<= MAX_FAN_SPEED
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be between "
|
||||||
|
f"{MIN_FAN_SPEED} and "
|
||||||
|
f"{MAX_FAN_SPEED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_thresholds(value: Any) -> tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
...
|
||||||
|
]:
|
||||||
|
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds must be a list"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not value:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
|
||||||
|
previous_ppm = None
|
||||||
|
previous_speed = None
|
||||||
|
|
||||||
|
for index, item in enumerate(value):
|
||||||
|
|
||||||
|
item = _require_dict(
|
||||||
|
f"co2.thresholds[{index}]",
|
||||||
|
item,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(item) - {
|
||||||
|
"ppm",
|
||||||
|
"speed",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown fields in "
|
||||||
|
f"co2.thresholds[{index}]: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "ppm" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].speed "
|
||||||
|
f"is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
ppm = item["ppm"]
|
||||||
|
|
||||||
|
if type(ppm) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if ppm <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"co2.thresholds[{index}].ppm "
|
||||||
|
f"must be > 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = _parse_speed(
|
||||||
|
(
|
||||||
|
f"co2.thresholds"
|
||||||
|
f"[{index}].speed"
|
||||||
|
),
|
||||||
|
item["speed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
previous_ppm is not None
|
||||||
|
and ppm <= previous_ppm
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"CO2 thresholds must be "
|
||||||
|
"strictly increasing"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
previous_speed is not None
|
||||||
|
and speed <= previous_speed
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO speeds must be "
|
||||||
|
"strictly increasing"
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
(
|
||||||
|
ppm,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_ppm = ppm
|
||||||
|
previous_speed = speed
|
||||||
|
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_co2(value: Any) -> Co2Config:
|
||||||
|
|
||||||
|
data = _require_dict(
|
||||||
|
"co2",
|
||||||
|
value,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(data) - {
|
||||||
|
"base_speed",
|
||||||
|
"hysteresis",
|
||||||
|
"thresholds",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown CO2 config fields: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "base_speed" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.base_speed is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "hysteresis" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "thresholds" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.thresholds is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
base_speed = _parse_speed(
|
||||||
|
"co2.base_speed",
|
||||||
|
data["base_speed"],
|
||||||
|
)
|
||||||
|
|
||||||
|
hysteresis = data["hysteresis"]
|
||||||
|
|
||||||
|
if type(hysteresis) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis must be "
|
||||||
|
"an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if hysteresis < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"co2.hysteresis must be >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
thresholds = _parse_thresholds(
|
||||||
|
data["thresholds"]
|
||||||
|
)
|
||||||
|
|
||||||
|
first_speed = thresholds[0][1]
|
||||||
|
|
||||||
|
if first_speed <= base_speed:
|
||||||
|
raise ValueError(
|
||||||
|
"First threshold speed must be "
|
||||||
|
"greater than base_speed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return Co2Config(
|
||||||
|
base_speed=base_speed,
|
||||||
|
hysteresis=hysteresis,
|
||||||
|
thresholds=thresholds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_auto_config(path: str | Path) -> AutoConfig:
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
with path.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
raw = yaml.safe_load(file)
|
||||||
|
|
||||||
|
data = _require_dict(
|
||||||
|
"AUTO config",
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown = set(data) - {
|
||||||
|
"version",
|
||||||
|
"check_interval",
|
||||||
|
"co2",
|
||||||
|
"temperature",
|
||||||
|
}
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown AUTO config fields: "
|
||||||
|
f"{sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
version = data.get("version")
|
||||||
|
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported AUTO config "
|
||||||
|
f"version: {version!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
check_interval = data.get(
|
||||||
|
"check_interval"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
type(check_interval) not in {
|
||||||
|
int,
|
||||||
|
float,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"check_interval must be a number"
|
||||||
|
)
|
||||||
|
|
||||||
|
if check_interval <= 0:
|
||||||
|
raise ValueError(
|
||||||
|
"check_interval must be > 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# CO2
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if "co2" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"co2 config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
co2 = _parse_co2(
|
||||||
|
data["co2"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Temperature
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if "temperature" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"temperature config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature_data = _require_dict(
|
||||||
|
"temperature",
|
||||||
|
data["temperature"],
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown_temperature = (
|
||||||
|
set(temperature_data)
|
||||||
|
- {
|
||||||
|
"hysteresis",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if unknown_temperature:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown temperature config fields: "
|
||||||
|
f"{sorted(unknown_temperature)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature_hysteresis = (
|
||||||
|
temperature_data.get(
|
||||||
|
"hysteresis"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
type(temperature_hysteresis)
|
||||||
|
not in {
|
||||||
|
int,
|
||||||
|
float,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be a number"
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature_hysteresis = float(
|
||||||
|
temperature_hysteresis
|
||||||
|
)
|
||||||
|
|
||||||
|
if temperature_hysteresis < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature = TemperatureConfig(
|
||||||
|
hysteresis=temperature_hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Result
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
return AutoConfig(
|
||||||
|
version=version,
|
||||||
|
check_interval=float(
|
||||||
|
check_interval
|
||||||
|
),
|
||||||
|
co2=co2,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from app.auto.co2_policy import Co2SpeedPolicy
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||||
|
|
||||||
|
class AutoController:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
schedule_service,
|
||||||
|
qingping_service,
|
||||||
|
tion_service,
|
||||||
|
policy: Co2SpeedPolicy | None = None,
|
||||||
|
temperature_policy: TemperaturePolicy | None = None,
|
||||||
|
interval: float = 5.0,
|
||||||
|
):
|
||||||
|
self._schedule = schedule_service
|
||||||
|
self._qingping = qingping_service
|
||||||
|
self._tion = tion_service
|
||||||
|
self._policy = policy
|
||||||
|
self._temperature_policy = temperature_policy
|
||||||
|
|
||||||
|
self._interval = interval
|
||||||
|
|
||||||
|
self._task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
self._state = "inactive"
|
||||||
|
self._reason: str | None = None
|
||||||
|
|
||||||
|
self._target_speed: int | None = None
|
||||||
|
self._auto_speed: int | None = None
|
||||||
|
|
||||||
|
self._target_heater: bool | None = None
|
||||||
|
self._auto_heater: bool | None = None
|
||||||
|
self._target_temperature: int | None = None
|
||||||
|
|
||||||
|
self._temperature: float | int | None = None
|
||||||
|
self._temperature_source: str | None = None
|
||||||
|
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
|
||||||
|
if self._task is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task = asyncio.create_task( self._loop() )
|
||||||
|
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
|
||||||
|
if self._task is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = str(exc)
|
||||||
|
|
||||||
|
await asyncio.sleep( self._interval )
|
||||||
|
|
||||||
|
|
||||||
|
async def _process(self) -> None:
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self._schedule.resolve(now)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Расписание поставлено на паузу.
|
||||||
|
#
|
||||||
|
# Это полноценный ручной режим:
|
||||||
|
# AUTO вообще не имеет права управлять Tion.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if getattr(
|
||||||
|
self._schedule,
|
||||||
|
"paused",
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
self._state = "suspended"
|
||||||
|
self._reason = "schedule_paused"
|
||||||
|
|
||||||
|
# После перехода в ручной режим пользователь
|
||||||
|
# может изменить эти параметры напрямую.
|
||||||
|
# Поэтому старые значения AUTO больше
|
||||||
|
# нельзя считать достоверными.
|
||||||
|
self._target_speed = None
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
self._target_heater = None
|
||||||
|
self._auto_heater = None
|
||||||
|
|
||||||
|
self._target_temperature = None
|
||||||
|
|
||||||
|
self._temperature = None
|
||||||
|
self._temperature_source = None
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Активен ручной override.
|
||||||
|
#
|
||||||
|
# Пока пользователь вручную управляет Tion,
|
||||||
|
# AUTO вообще не вмешивается.
|
||||||
|
# ----------------------------------------------
|
||||||
|
if self._schedule.override_active:
|
||||||
|
self._state = "suspended"
|
||||||
|
self._reason = "manual_override"
|
||||||
|
# AUTO больше не может считать,
|
||||||
|
# что знает реальное состояние speed/heater:
|
||||||
|
# пользователь мог изменить их вручную.
|
||||||
|
self._target_speed = None
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
self._target_heater = None
|
||||||
|
self._auto_heater = None
|
||||||
|
self._target_temperature = None
|
||||||
|
|
||||||
|
self._temperature = None
|
||||||
|
self._temperature_source = None
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# AUTO сейчас не активен
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
not resolution.enabled
|
||||||
|
or not resolution.auto_active
|
||||||
|
):
|
||||||
|
self._state = "inactive"
|
||||||
|
self._reason = None
|
||||||
|
# Управление возвращается ScheduleService.
|
||||||
|
# После этого AUTO уже не знает фактическое
|
||||||
|
# состояние speed/heater, поэтому забываем его.
|
||||||
|
self._target_speed = None
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
self._target_heater = None
|
||||||
|
self._auto_heater = None
|
||||||
|
self._target_temperature = None
|
||||||
|
|
||||||
|
self._temperature = None
|
||||||
|
self._temperature_source = None
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
fallback_speed = resolution.auto_fallback_speed
|
||||||
|
|
||||||
|
if fallback_speed is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"AUTO is active but "
|
||||||
|
"fallback speed is missing"
|
||||||
|
)
|
||||||
|
# ----------------------------------------------
|
||||||
|
# CO2 policy недоступна.
|
||||||
|
#
|
||||||
|
# Например, auto.yaml не загрузился.
|
||||||
|
# AUTO работает в аварийном fallback-only режиме.
|
||||||
|
# ----------------------------------------------
|
||||||
|
if self._policy is None:
|
||||||
|
self._state = "fallback"
|
||||||
|
self._reason = "auto_policy_unavailable"
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
await self._set_speed(fallback_speed)
|
||||||
|
|
||||||
|
if self._temperature_policy is not None:
|
||||||
|
await self._process_heater(resolution)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
qingping_state = self._qingping.state
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Qingping исправен
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._qingping.online
|
||||||
|
and qingping_state.co2 is not None
|
||||||
|
):
|
||||||
|
target_speed = (
|
||||||
|
self._policy.select_speed(
|
||||||
|
co2=qingping_state.co2,
|
||||||
|
current_speed=self._auto_speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._state = "active"
|
||||||
|
self._reason = None
|
||||||
|
|
||||||
|
await self._set_speed(
|
||||||
|
target_speed
|
||||||
|
)
|
||||||
|
|
||||||
|
self._auto_speed = target_speed
|
||||||
|
|
||||||
|
if self._temperature_policy is not None:
|
||||||
|
await self._process_heater(resolution)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# AUTO не может работать.
|
||||||
|
# Переходим на fallback.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
if not self._qingping.online:
|
||||||
|
reason = "qingping_offline"
|
||||||
|
|
||||||
|
else:
|
||||||
|
reason = "co2_missing"
|
||||||
|
|
||||||
|
self._state = "fallback"
|
||||||
|
self._reason = reason
|
||||||
|
|
||||||
|
self._auto_speed = None
|
||||||
|
|
||||||
|
await self._set_speed(fallback_speed)
|
||||||
|
|
||||||
|
if self._temperature_policy is not None:
|
||||||
|
await self._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_heater(self, resolution) -> None:
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Берём целевую температуру из накопленных
|
||||||
|
# настроек расписания.
|
||||||
|
#
|
||||||
|
# Например:
|
||||||
|
#
|
||||||
|
# SET target_temp=20
|
||||||
|
# AUTO
|
||||||
|
#
|
||||||
|
# Во время AUTO target_temp остаётся 20.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
target_temp = resolution.auto_target_temp
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Получаем фактическую температуру.
|
||||||
|
#
|
||||||
|
# _get_temperature() сам выбирает:
|
||||||
|
#
|
||||||
|
# 1. Qingping
|
||||||
|
# 2. Tion как резерв
|
||||||
|
# 3. None, если оба источника недоступны
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
temperature, source = self._get_temperature()
|
||||||
|
|
||||||
|
self._temperature = temperature
|
||||||
|
self._temperature_source = source
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Нет температурной policy.
|
||||||
|
#
|
||||||
|
# AUTO не умеет безопасно принять решение
|
||||||
|
# о нагреве → выключаем heater.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._temperature_policy is None:
|
||||||
|
await self._set_heater(False)
|
||||||
|
self._auto_heater = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Нет целевой температуры.
|
||||||
|
#
|
||||||
|
# Непонятно, до какой температуры греть.
|
||||||
|
# Поэтому heater OFF.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if target_temp is None:
|
||||||
|
await self._set_heater(False)
|
||||||
|
self._auto_heater = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Нет достоверной фактической температуры.
|
||||||
|
#
|
||||||
|
# Ни Qingping, ни резервный датчик Tion
|
||||||
|
# использовать нельзя.
|
||||||
|
#
|
||||||
|
# Heater OFF.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if temperature is None:
|
||||||
|
await self._set_heater(False)
|
||||||
|
self._auto_heater = False
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Есть всё необходимое:
|
||||||
|
#
|
||||||
|
# - фактическая температура;
|
||||||
|
# - target_temp;
|
||||||
|
# - TemperaturePolicy.
|
||||||
|
#
|
||||||
|
# Policy решает, нужно ли сейчас греть.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
heater_required = (
|
||||||
|
self._temperature_policy
|
||||||
|
.heater_required(
|
||||||
|
temperature=temperature,
|
||||||
|
target_temp=target_temp,
|
||||||
|
heater_on=(
|
||||||
|
self._auto_heater
|
||||||
|
is True
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if heater_required:
|
||||||
|
# Наш внешний регулятор решил, что нужно греть.
|
||||||
|
#
|
||||||
|
# Сначала поднимаем внутреннюю уставку Tion
|
||||||
|
# до максимума, чтобы встроенный термостат
|
||||||
|
# не заблокировал нагрев по своему in_temp.
|
||||||
|
await self._set_target_temperature(MAX_TARGET_TEMP)
|
||||||
|
# После этого разрешаем нагреватель.
|
||||||
|
await self._set_heater(True)
|
||||||
|
else:
|
||||||
|
# Целевая температура по внешнему датчику
|
||||||
|
# достигнута — нагрев запрещаем.
|
||||||
|
await self._set_heater(False)
|
||||||
|
|
||||||
|
self._auto_heater = heater_required
|
||||||
|
|
||||||
|
|
||||||
|
def _get_temperature(self) -> tuple[float | int | None, str | None]:
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Основной источник — Qingping.
|
||||||
|
#
|
||||||
|
# Используем температуру Qingping только если
|
||||||
|
# сам поток данных датчика сейчас считается online.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
qingping_state = self._qingping.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._qingping.online
|
||||||
|
and qingping_state.temperature is not None
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
qingping_state.temperature,
|
||||||
|
"qingping",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Qingping недоступен или температура отсутствует.
|
||||||
|
#
|
||||||
|
# Пробуем резервный датчик температуры Tion.
|
||||||
|
#
|
||||||
|
# Важно проверять tion.online:
|
||||||
|
# TionService может хранить последнее состояние,
|
||||||
|
# даже если связь с устройством уже потеряна.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
tion_state = self._tion.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._tion.online
|
||||||
|
and tion_state is not None
|
||||||
|
and tion_state.in_temp is not None
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
tion_state.in_temp,
|
||||||
|
"tion",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Достоверной температуры нет вообще.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_speed(self, speed: int) -> None:
|
||||||
|
|
||||||
|
if self._target_speed == speed:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda controller:
|
||||||
|
controller.set_speed(speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._target_speed = speed
|
||||||
|
|
||||||
|
async def _set_heater(self,enabled: bool) -> None:
|
||||||
|
|
||||||
|
# Если AutoController уже установил именно такое
|
||||||
|
# состояние нагревателя, повторную команду не шлём.
|
||||||
|
if self._target_heater == enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda controller:
|
||||||
|
controller.heater_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda controller:
|
||||||
|
controller.heater_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Запоминаем состояние только после того,
|
||||||
|
# как команда успешно выполнилась.
|
||||||
|
self._target_heater = enabled
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
if self._target_temperature == temperature:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda controller:
|
||||||
|
controller.set_target_temperature(
|
||||||
|
temperature
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._target_temperature = temperature
|
||||||
|
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {
|
||||||
|
"state": self._state,
|
||||||
|
"reason": self._reason,
|
||||||
|
|
||||||
|
"target_speed": self._target_speed,
|
||||||
|
"auto_speed": self._auto_speed,
|
||||||
|
|
||||||
|
"target_heater": self._target_heater,
|
||||||
|
"auto_heater": self._auto_heater,
|
||||||
|
|
||||||
|
"temperature": self._temperature,
|
||||||
|
"temperature_source": self._temperature_source,
|
||||||
|
|
||||||
|
"last_error": self._last_error,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from .config import TemperatureConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TemperaturePolicy:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: TemperatureConfig,
|
||||||
|
):
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
def heater_required(
|
||||||
|
self,
|
||||||
|
temperature: float,
|
||||||
|
target_temp: float,
|
||||||
|
heater_on: bool,
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
|
# Нагреватель уже включён.
|
||||||
|
#
|
||||||
|
# Продолжаем греть, пока температура
|
||||||
|
# не достигла целевой.
|
||||||
|
if heater_on:
|
||||||
|
return (
|
||||||
|
temperature
|
||||||
|
< target_temp
|
||||||
|
)
|
||||||
|
|
||||||
|
# Нагреватель выключен.
|
||||||
|
#
|
||||||
|
# Повторно включаем его только после
|
||||||
|
# падения температуры ниже нижней
|
||||||
|
# границы гистерезиса.
|
||||||
|
return (
|
||||||
|
temperature
|
||||||
|
< (
|
||||||
|
target_temp
|
||||||
|
- self._config.hysteresis
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
# Константы и классы
|
||||||
|
|
||||||
|
# Константы
|
||||||
|
|
||||||
|
# MAC Bluetooth бризера
|
||||||
|
TION_MAC = "d1:74:9b:eb:ee:a6"
|
||||||
|
|
||||||
|
QINGPING_MAC = "CCB5D131BA93"
|
||||||
|
QINGPING_MQTT_HOST = "192.168.7.3"
|
||||||
|
QINGPING_MQTT_PORT = 1883
|
||||||
|
|
||||||
|
|
||||||
|
MIN_FAN_SPEED = 1
|
||||||
|
MAX_FAN_SPEED = 6
|
||||||
|
|
||||||
|
MIN_TARGET_TEMP = 5
|
||||||
|
MAX_TARGET_TEMP = 30
|
||||||
|
|
||||||
|
AIR_MODE_OUTSIDE = "outside"
|
||||||
|
AIR_MODE_RECIRCULATION = "recirculation"
|
||||||
|
|
||||||
|
SUPPORTED_AIR_MODES = {
|
||||||
|
AIR_MODE_OUTSIDE,
|
||||||
|
AIR_MODE_RECIRCULATION,
|
||||||
|
}
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
SCHEDULE_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTO_CONFIG_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
WATCHDOG_INTERVAL = 5.0
|
||||||
|
|
||||||
|
# Type 13 обычно приходит примерно раз в 60 секунд.
|
||||||
|
# Даём дополнительный запас.
|
||||||
|
HEARTBEAT_STALE_AFTER = 90.0
|
||||||
|
|
||||||
|
# После появления устройства ждём первый sensor sample
|
||||||
|
# не дольше этого времени.
|
||||||
|
FIRST_SAMPLE_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
# Если новые sensor samples не приходят дольше этого времени,
|
||||||
|
# считаем sensor stream зависшим.
|
||||||
|
SAMPLE_STALE_AFTER = 45.0
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
QINGPING_SAMPLE_TIMEOUT = 60.0
|
||||||
|
QINGPING_RECOVERY_TIMEOUT = 30.0
|
||||||
|
|
||||||
|
#Классы
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# from dataclasses import asdict, dataclass
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# @dataclass(frozen=True, slots=True)
|
||||||
|
# class QingpingState:
|
||||||
|
# temperature: float
|
||||||
|
# humidity: float
|
||||||
|
# co2: int
|
||||||
|
# pm25: int
|
||||||
|
# pm10: int
|
||||||
|
# rssi: int | None = None
|
||||||
|
#
|
||||||
|
# def to_dict(self) -> dict:
|
||||||
|
# return asdict(self)
|
||||||
|
#
|
||||||
|
#
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QingpingState:
|
||||||
|
temperature: float | None = None
|
||||||
|
humidity: float | None = None
|
||||||
|
co2: int | None = None
|
||||||
|
pm25: int | None = None
|
||||||
|
pm10: int | None = None
|
||||||
|
battery: int | None = None
|
||||||
|
|
||||||
|
sample_timestamp: int | None = None
|
||||||
|
sample_received_at: datetime | None = None
|
||||||
|
last_message_at: datetime | None = None
|
||||||
|
|
||||||
|
wifi_rssi: int | None = None
|
||||||
|
firmware: str | None = None
|
||||||
|
mqtt_connected: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
result = asdict(self)
|
||||||
|
|
||||||
|
for key in ("sample_received_at", "last_message_at"):
|
||||||
|
if result[key] is not None:
|
||||||
|
result[key] = result[key].isoformat()
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
from app.qingping.models import QingpingState
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cgdn1(data: bytes, rssi: int | None = None) -> QingpingState | None:
|
||||||
|
|
||||||
|
# Восемь первых байт — заголовок CGDN1.
|
||||||
|
if len(data) < 8:
|
||||||
|
return None
|
||||||
|
|
||||||
|
temperature = None
|
||||||
|
humidity = None
|
||||||
|
pm25 = None
|
||||||
|
pm10 = None
|
||||||
|
co2 = None
|
||||||
|
|
||||||
|
pos = 8
|
||||||
|
|
||||||
|
while pos + 2 <= len(data):
|
||||||
|
field_type = data[pos]
|
||||||
|
length = data[pos + 1]
|
||||||
|
|
||||||
|
pos += 2
|
||||||
|
|
||||||
|
if pos + length > len(data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = data[pos:pos + length]
|
||||||
|
pos += length
|
||||||
|
|
||||||
|
# Temperature + Humidity
|
||||||
|
if field_type == 0x01 and length == 4:
|
||||||
|
temperature = (
|
||||||
|
int.from_bytes(
|
||||||
|
value[0:2],
|
||||||
|
byteorder="little",
|
||||||
|
signed=True,
|
||||||
|
)
|
||||||
|
/ 10
|
||||||
|
)
|
||||||
|
|
||||||
|
humidity = (
|
||||||
|
int.from_bytes(
|
||||||
|
value[2:4],
|
||||||
|
byteorder="little",
|
||||||
|
signed=False,
|
||||||
|
)
|
||||||
|
/ 10
|
||||||
|
)
|
||||||
|
|
||||||
|
# PM2.5 + PM10
|
||||||
|
elif field_type == 0x12 and length == 4:
|
||||||
|
pm25 = int.from_bytes(
|
||||||
|
value[0:2],
|
||||||
|
byteorder="little",
|
||||||
|
)
|
||||||
|
|
||||||
|
pm10 = int.from_bytes(
|
||||||
|
value[2:4],
|
||||||
|
byteorder="little",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CO2
|
||||||
|
elif field_type == 0x13 and length == 2:
|
||||||
|
co2 = int.from_bytes(
|
||||||
|
value,
|
||||||
|
byteorder="little",
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
value is None
|
||||||
|
for value in (
|
||||||
|
temperature,
|
||||||
|
humidity,
|
||||||
|
pm25,
|
||||||
|
pm10,
|
||||||
|
co2,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return QingpingState(
|
||||||
|
temperature=temperature,
|
||||||
|
humidity=humidity,
|
||||||
|
co2=co2,
|
||||||
|
pm25=pm25,
|
||||||
|
pm10=pm10,
|
||||||
|
rssi=rssi,
|
||||||
|
)
|
||||||
@@ -0,0 +1,900 @@
|
|||||||
|
# from datetime import datetime, timedelta, timezone
|
||||||
|
#
|
||||||
|
# from bleak import BleakScanner
|
||||||
|
# from bleak.backends.device import BLEDevice
|
||||||
|
# from bleak.backends.scanner import AdvertisementData
|
||||||
|
#
|
||||||
|
# from app.qingping.models import QingpingState
|
||||||
|
# from app.qingping.parser import parse_cgdn1
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# QINGPING_SERVICE_UUID = "0000fdcd-0000-1000-8000-00805f9b34fb"
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# class QingpingService:
|
||||||
|
# def __init__(self, mac: str, stale_after: float = 30.0):
|
||||||
|
# self._mac = mac.upper()
|
||||||
|
# self._stale_after = stale_after
|
||||||
|
#
|
||||||
|
# self._scanner: BleakScanner | None = None
|
||||||
|
# self._state: QingpingState | None = None
|
||||||
|
#
|
||||||
|
# self._last_seen: datetime | None = None
|
||||||
|
# self._last_error: str | None = None
|
||||||
|
#
|
||||||
|
# self._running = False
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def state(self) -> QingpingState | None:
|
||||||
|
# return self._state
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def running(self) -> bool:
|
||||||
|
# return self._running
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def last_seen(self) -> datetime | None:
|
||||||
|
# return self._last_seen
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def last_error(self) -> str | None:
|
||||||
|
# return self._last_error
|
||||||
|
#
|
||||||
|
# @property
|
||||||
|
# def online(self) -> bool:
|
||||||
|
# if not self._running:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# if self._last_seen is None:
|
||||||
|
# return False
|
||||||
|
#
|
||||||
|
# age = datetime.now(timezone.utc) - self._last_seen
|
||||||
|
#
|
||||||
|
# return age <= timedelta(
|
||||||
|
# seconds=self._stale_after
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# async def start(self):
|
||||||
|
# if self._running:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# self._scanner = BleakScanner(
|
||||||
|
# self._on_advertisement,
|
||||||
|
# # service_uuids=[
|
||||||
|
# # QINGPING_SERVICE_UUID,
|
||||||
|
# # ],
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# await self._scanner.start()
|
||||||
|
#
|
||||||
|
# self._running = True
|
||||||
|
# self._last_error = None
|
||||||
|
#
|
||||||
|
# except Exception as exc:
|
||||||
|
# self._scanner = None
|
||||||
|
# self._running = False
|
||||||
|
# self._last_error = str(exc)
|
||||||
|
#
|
||||||
|
# raise
|
||||||
|
#
|
||||||
|
# async def stop(self):
|
||||||
|
# scanner = self._scanner
|
||||||
|
#
|
||||||
|
# self._scanner = None
|
||||||
|
# self._running = False
|
||||||
|
#
|
||||||
|
# if scanner is not None:
|
||||||
|
# await scanner.stop()
|
||||||
|
#
|
||||||
|
# # def _on_advertisement(
|
||||||
|
# # self,
|
||||||
|
# # device: BLEDevice,
|
||||||
|
# # advertisement: AdvertisementData,
|
||||||
|
# # ):
|
||||||
|
# # if device.address.upper() != self._mac:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # data = advertisement.service_data.get(
|
||||||
|
# # QINGPING_SERVICE_UUID
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # if not data:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # try:
|
||||||
|
# # state = parse_cgdn1(
|
||||||
|
# # data,
|
||||||
|
# # rssi=getattr(
|
||||||
|
# # advertisement,
|
||||||
|
# # "rssi",
|
||||||
|
# # None,
|
||||||
|
# # ),
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # if state is None:
|
||||||
|
# # return
|
||||||
|
# #
|
||||||
|
# # self._state = state
|
||||||
|
# #
|
||||||
|
# # self._last_seen = datetime.now(
|
||||||
|
# # timezone.utc
|
||||||
|
# # )
|
||||||
|
# #
|
||||||
|
# # self._last_error = None
|
||||||
|
# #
|
||||||
|
# # except Exception as exc:
|
||||||
|
# # self._last_error = str(exc)
|
||||||
|
#
|
||||||
|
# def _on_advertisement(
|
||||||
|
# self,
|
||||||
|
# device: BLEDevice,
|
||||||
|
# advertisement: AdvertisementData,
|
||||||
|
# ):
|
||||||
|
# data = advertisement.service_data.get(
|
||||||
|
# QINGPING_SERVICE_UUID
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# if not data:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# print(
|
||||||
|
# "QINGPING:",
|
||||||
|
# device.address,
|
||||||
|
# device.name,
|
||||||
|
# data.hex(" "),
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# if device.address.upper() != self._mac:
|
||||||
|
# print(
|
||||||
|
# "MAC mismatch:",
|
||||||
|
# device.address,
|
||||||
|
# "!=",
|
||||||
|
# self._mac,
|
||||||
|
# )
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# try:
|
||||||
|
# state = parse_cgdn1(
|
||||||
|
# data,
|
||||||
|
# rssi=getattr(
|
||||||
|
# advertisement,
|
||||||
|
# "rssi",
|
||||||
|
# None,
|
||||||
|
# ),
|
||||||
|
# )
|
||||||
|
#
|
||||||
|
# print("PARSED:", state)
|
||||||
|
#
|
||||||
|
# if state is None:
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# self._state = state
|
||||||
|
# self._last_seen = datetime.now(
|
||||||
|
# timezone.utc
|
||||||
|
# )
|
||||||
|
# self._last_error = None
|
||||||
|
#
|
||||||
|
# except Exception as exc:
|
||||||
|
# self._last_error = str(exc)
|
||||||
|
# print("Qingping parse error:", exc)
|
||||||
|
#
|
||||||
|
# async def __aenter__(self):
|
||||||
|
# await self.start()
|
||||||
|
# return self
|
||||||
|
#
|
||||||
|
# async def __aexit__(
|
||||||
|
# self,
|
||||||
|
# exc_type,
|
||||||
|
# exc_val,
|
||||||
|
# exc_tb,
|
||||||
|
# ):
|
||||||
|
# await self.stop()
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from .models import QingpingState
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
WATCHDOG_INTERVAL,
|
||||||
|
QINGPING_SAMPLE_TIMEOUT,
|
||||||
|
QINGPING_RECOVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QingpingService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str,
|
||||||
|
port: int = 1883,
|
||||||
|
mac: str = "CCB5D131BA93",
|
||||||
|
):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self._mac = mac
|
||||||
|
|
||||||
|
self._up_topic = f"qingping/{mac}/up"
|
||||||
|
self._down_topic = f"qingping/{mac}/down"
|
||||||
|
|
||||||
|
self._state = QingpingState()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._client: mqtt.Client | None = None
|
||||||
|
self._watchdog_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
self._last_sample_monotonic: float | None = None
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> QingpingState:
|
||||||
|
with self._lock:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online(self) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
return (
|
||||||
|
self._state.mqtt_connected
|
||||||
|
and self._device_online
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="tioncontroller-qingping",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.on_connect = self._on_connect
|
||||||
|
client.on_disconnect = self._on_disconnect
|
||||||
|
client.on_message = self._on_message
|
||||||
|
|
||||||
|
client.reconnect_delay_set(
|
||||||
|
min_delay=1,
|
||||||
|
max_delay=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._client = client
|
||||||
|
|
||||||
|
client.connect_async(
|
||||||
|
self._host,
|
||||||
|
self._port,
|
||||||
|
keepalive=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.loop_start()
|
||||||
|
|
||||||
|
self._watchdog_task = asyncio.create_task(
|
||||||
|
self._watchdog_loop()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._watchdog_task is not None:
|
||||||
|
self._watchdog_task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._watchdog_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._watchdog_task = None
|
||||||
|
|
||||||
|
if self._client is not None:
|
||||||
|
self._client.disconnect()
|
||||||
|
self._client.loop_stop()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
state = self._state
|
||||||
|
|
||||||
|
online = (
|
||||||
|
state.mqtt_connected
|
||||||
|
and self._device_online
|
||||||
|
)
|
||||||
|
|
||||||
|
recovery_waiting = (
|
||||||
|
self._recovery_waiting
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**state.to_dict(),
|
||||||
|
"online": online,
|
||||||
|
"recovery_waiting": recovery_waiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _on_connect(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
_userdata,
|
||||||
|
_flags,
|
||||||
|
reason_code,
|
||||||
|
_properties=None,
|
||||||
|
) -> None:
|
||||||
|
if reason_code != 0:
|
||||||
|
logger.error(
|
||||||
|
"Qingping MQTT connection failed: %s",
|
||||||
|
reason_code,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Qingping MQTT connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
client.subscribe(self._up_topic)
|
||||||
|
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
mqtt_connected=True,
|
||||||
|
temperature=None,
|
||||||
|
humidity=None,
|
||||||
|
co2=None,
|
||||||
|
pm25=None,
|
||||||
|
pm10=None,
|
||||||
|
battery=None,
|
||||||
|
sample_timestamp=None,
|
||||||
|
sample_received_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_sample_monotonic = None
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
self._recovery_waiting = True
|
||||||
|
self._recovery_started_monotonic = (
|
||||||
|
now_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Qingping startup initialization"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_recovery()
|
||||||
|
|
||||||
|
def _on_disconnect(
|
||||||
|
self,
|
||||||
|
_client,
|
||||||
|
_userdata,
|
||||||
|
_disconnect_flags,
|
||||||
|
reason_code,
|
||||||
|
_properties=None,
|
||||||
|
) -> None:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping MQTT disconnected: %s",
|
||||||
|
reason_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
mqtt_connected=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic = None
|
||||||
|
|
||||||
|
def _on_message(
|
||||||
|
self,
|
||||||
|
_client,
|
||||||
|
_userdata,
|
||||||
|
message,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(
|
||||||
|
message.payload.decode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
message_type = int(
|
||||||
|
payload.get("type")
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Qingping MQTT packet received: type=%s",
|
||||||
|
message_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Любой пакет означает, что устройство
|
||||||
|
# физически присутствует в MQTT.
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
last_message_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_recovery = False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if (
|
||||||
|
not self._device_online
|
||||||
|
and
|
||||||
|
not self._recovery_waiting
|
||||||
|
):
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
temperature=None,
|
||||||
|
humidity=None,
|
||||||
|
co2=None,
|
||||||
|
pm25=None,
|
||||||
|
pm10=None,
|
||||||
|
battery=None,
|
||||||
|
sample_timestamp=None,
|
||||||
|
sample_received_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_sample_monotonic = None
|
||||||
|
|
||||||
|
self._device_online = True
|
||||||
|
|
||||||
|
self._recovery_waiting = True
|
||||||
|
self._recovery_started_monotonic = (
|
||||||
|
now_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
start_recovery = True
|
||||||
|
|
||||||
|
if start_recovery:
|
||||||
|
logger.info(
|
||||||
|
"Qingping packet received while offline: "
|
||||||
|
"type=%s -> starting recovery",
|
||||||
|
message_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._send_recovery()
|
||||||
|
|
||||||
|
# Единственный пакет, который реально
|
||||||
|
# обрабатываем как данные.
|
||||||
|
if message_type == 17:
|
||||||
|
self._handle_sensor_data(
|
||||||
|
payload,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if message_type == 13:
|
||||||
|
self._handle_heartbeat(
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Qingping service packet received: type=%s",
|
||||||
|
message_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Qingping MQTT packet processing failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_heartbeat(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
) -> None:
|
||||||
|
wifi_info = payload.get("wifi_info")
|
||||||
|
|
||||||
|
wifi_rssi = None
|
||||||
|
|
||||||
|
if isinstance(wifi_info, str):
|
||||||
|
parts = wifi_info.split(",")
|
||||||
|
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
wifi_rssi = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping invalid RSSI in wifi_info: %r",
|
||||||
|
wifi_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
firmware = payload.get("sw_version")
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
wifi_rssi=wifi_rssi,
|
||||||
|
firmware=firmware,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Qingping heartbeat updated: "
|
||||||
|
"rssi=%s firmware=%s",
|
||||||
|
wifi_rssi,
|
||||||
|
firmware,
|
||||||
|
)
|
||||||
|
def _handle_sensor_data(
|
||||||
|
self,
|
||||||
|
payload: dict,
|
||||||
|
received_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
sensor_data = payload.get("sensorData")
|
||||||
|
|
||||||
|
if not isinstance(sensor_data, list):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not sensor_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
sample = max(
|
||||||
|
sensor_data,
|
||||||
|
key=self._sample_timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
sample_timestamp = self._sample_timestamp(
|
||||||
|
sample
|
||||||
|
)
|
||||||
|
|
||||||
|
if sample_timestamp <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_timestamp = (
|
||||||
|
self.state.sample_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# CGDN1 после запуска может несколько раз
|
||||||
|
# присылать одну и ту же историческую точку.
|
||||||
|
if (
|
||||||
|
current_timestamp is not None
|
||||||
|
and sample_timestamp <= current_timestamp
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
temperature = self._value(
|
||||||
|
sample,
|
||||||
|
"temperature",
|
||||||
|
)
|
||||||
|
humidity = self._value(
|
||||||
|
sample,
|
||||||
|
"humidity",
|
||||||
|
)
|
||||||
|
co2 = self._value(
|
||||||
|
sample,
|
||||||
|
"co2",
|
||||||
|
)
|
||||||
|
pm25 = self._value(
|
||||||
|
sample,
|
||||||
|
"pm25",
|
||||||
|
)
|
||||||
|
pm10 = self._value(
|
||||||
|
sample,
|
||||||
|
"pm10",
|
||||||
|
)
|
||||||
|
battery = self._value(
|
||||||
|
sample,
|
||||||
|
"battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
now_monotonic = time.monotonic()
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
current_timestamp = (
|
||||||
|
self._state.sample_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
# CGDN1 может повторять одну историческую точку.
|
||||||
|
# Такой пакет НЕ считается новым измерением.
|
||||||
|
if (
|
||||||
|
current_timestamp is not None
|
||||||
|
and sample_timestamp <= current_timestamp
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
"Qingping duplicate sensor sample ignored: "
|
||||||
|
"timestamp=%s current=%s",
|
||||||
|
sample_timestamp,
|
||||||
|
current_timestamp,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
was_recovering = self._recovery_waiting
|
||||||
|
was_offline = not self._device_online
|
||||||
|
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
temperature=temperature,
|
||||||
|
humidity=humidity,
|
||||||
|
co2=co2,
|
||||||
|
pm25=pm25,
|
||||||
|
pm10=pm10,
|
||||||
|
battery=battery,
|
||||||
|
sample_timestamp=sample_timestamp,
|
||||||
|
sample_received_at=received_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_sample_monotonic = (
|
||||||
|
now_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
self._device_online = True
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic = None
|
||||||
|
|
||||||
|
# if was_offline or was_recovering:
|
||||||
|
# logger.info(
|
||||||
|
# "Qingping sensor stream online: "
|
||||||
|
# "timestamp=%s co2=%s",
|
||||||
|
# sample_timestamp,
|
||||||
|
# co2,
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# logger.debug(
|
||||||
|
# "Qingping sensor sample accepted: "
|
||||||
|
# "timestamp=%s co2=%s",
|
||||||
|
# sample_timestamp,
|
||||||
|
# co2,
|
||||||
|
# )
|
||||||
|
|
||||||
|
async def _watchdog_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(
|
||||||
|
WATCHDOG_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
|
self._watchdog_tick(
|
||||||
|
time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _watchdog_tick(
|
||||||
|
self,
|
||||||
|
now: float,
|
||||||
|
) -> None:
|
||||||
|
recovery_timeout = None
|
||||||
|
sample_timeout = None
|
||||||
|
invalid_recovery_state = False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
|
||||||
|
# Ждём type 17 после recovery.
|
||||||
|
if self._recovery_waiting:
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._recovery_started_monotonic
|
||||||
|
is None
|
||||||
|
):
|
||||||
|
invalid_recovery_state = True
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
elapsed = (
|
||||||
|
now
|
||||||
|
- self._recovery_started_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
elapsed
|
||||||
|
> QINGPING_RECOVERY_TIMEOUT
|
||||||
|
):
|
||||||
|
recovery_timeout = elapsed
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic = None
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
# Нормальная работа:
|
||||||
|
# следим за последним НОВЫМ type 17.
|
||||||
|
elif self._last_sample_monotonic is not None:
|
||||||
|
|
||||||
|
elapsed = (
|
||||||
|
now
|
||||||
|
- self._last_sample_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
elapsed
|
||||||
|
> QINGPING_SAMPLE_TIMEOUT
|
||||||
|
and
|
||||||
|
self._device_online
|
||||||
|
):
|
||||||
|
sample_timeout = elapsed
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
if invalid_recovery_state:
|
||||||
|
logger.error(
|
||||||
|
"Qingping invalid recovery state: "
|
||||||
|
"recovery_waiting=True but "
|
||||||
|
"recovery_started_monotonic=None"
|
||||||
|
)
|
||||||
|
|
||||||
|
if recovery_timeout is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping recovery timeout: "
|
||||||
|
"no type 17 for %.1f sec "
|
||||||
|
"-> offline",
|
||||||
|
recovery_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sample_timeout is not None:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping sensor stream lost: "
|
||||||
|
"no type 17 for %.1f sec "
|
||||||
|
"-> offline",
|
||||||
|
sample_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sample_timestamp(sample: dict) -> int:
|
||||||
|
|
||||||
|
timestamp = sample.get("timestamp")
|
||||||
|
|
||||||
|
if isinstance(timestamp, dict):
|
||||||
|
timestamp = timestamp.get("value")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(timestamp)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _value(sample: dict, key: str):
|
||||||
|
value = sample.get(key)
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value.get("value")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
async def _watchdog_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(
|
||||||
|
WATCHDOG_INTERVAL
|
||||||
|
)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# СЦЕНАРИЙ 1
|
||||||
|
#
|
||||||
|
# Recovery уже отправлен.
|
||||||
|
# Ждём type 17 максимум 30 секунд.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._recovery_waiting:
|
||||||
|
|
||||||
|
if self._recovery_started_monotonic is None:
|
||||||
|
logger.error(
|
||||||
|
"Qingping invalid recovery state: "
|
||||||
|
"recovery_waiting=True but "
|
||||||
|
"recovery_started_monotonic=None"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._device_online = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
elapsed = now - self._recovery_started_monotonic
|
||||||
|
|
||||||
|
if elapsed > QINGPING_RECOVERY_TIMEOUT:
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Qingping recovery timeout: "
|
||||||
|
"no type 17 for %.1f sec "
|
||||||
|
"-> offline",
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._recovery_waiting = False
|
||||||
|
self._recovery_started_monotonic = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# СЦЕНАРИЙ 2
|
||||||
|
#
|
||||||
|
# До сих пор не получили вообще ни одного
|
||||||
|
# измерения type 17.
|
||||||
|
#
|
||||||
|
# Ничего делать не надо.
|
||||||
|
# Первый любой MQTT-пакет запустит recovery.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._last_sample_monotonic is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# СЦЕНАРИЙ 3
|
||||||
|
#
|
||||||
|
# Нормально работали, но type 17
|
||||||
|
# перестали приходить.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
elapsed = (
|
||||||
|
now
|
||||||
|
- self._last_sample_monotonic
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
elapsed
|
||||||
|
> QINGPING_SAMPLE_TIMEOUT
|
||||||
|
and
|
||||||
|
self._device_online
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Qingping sensor stream lost: "
|
||||||
|
"no type 17 for %.1f sec "
|
||||||
|
"-> offline",
|
||||||
|
elapsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._device_online = False
|
||||||
|
|
||||||
|
|
||||||
|
def _send_recovery(self) -> None:
|
||||||
|
if self._client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "17",
|
||||||
|
"timestamp": int(time.time()),
|
||||||
|
"setting": {
|
||||||
|
"report_interval": 15,
|
||||||
|
"collect_interval": 15,
|
||||||
|
"need_ack": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self._client.publish(
|
||||||
|
self._down_topic,
|
||||||
|
json.dumps(
|
||||||
|
payload,
|
||||||
|
separators=(",", ":"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.rc == mqtt.MQTT_ERR_SUCCESS:
|
||||||
|
logger.warning(
|
||||||
|
"Qingping recovery command sent"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to send Qingping recovery: %s",
|
||||||
|
result.rc,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reset_sample_session(self) -> None:
|
||||||
|
logger.debug(
|
||||||
|
"Qingping sensor session reset"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._state = replace(
|
||||||
|
self._state,
|
||||||
|
temperature=None,
|
||||||
|
humidity=None,
|
||||||
|
co2=None,
|
||||||
|
pm25=None,
|
||||||
|
pm10=None,
|
||||||
|
battery=None,
|
||||||
|
sample_timestamp=None,
|
||||||
|
sample_received_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._last_sample_monotonic = None
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .controller import TionController
|
||||||
|
from .models import TionState
|
||||||
|
from .service import TionService
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"TionController",
|
||||||
|
"TionService",
|
||||||
|
"TionState",
|
||||||
|
]
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
from bleak import BleakScanner
|
||||||
|
from tion_btle import TionS4
|
||||||
|
|
||||||
|
from .models import TionState
|
||||||
|
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
class TionController:
|
||||||
|
"""
|
||||||
|
Высокоуровневый контроллер Tion 4S.
|
||||||
|
|
||||||
|
Один экземпляр TionController должен владеть одним BLE-соединением
|
||||||
|
с бризером на протяжении всей работы приложения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mac: str):
|
||||||
|
self._mac = mac
|
||||||
|
self._device: TionS4 | None = None
|
||||||
|
|
||||||
|
# Не позволяем двум частям программы одновременно работать с BLE.
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Показывает, что мы удерживаем внешнее соединение tion-btle.
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
# Последнее успешно прочитанное состояние.
|
||||||
|
self._state: TionState | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mac(self) -> str:
|
||||||
|
return self._mac
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return (
|
||||||
|
self._device is not None
|
||||||
|
and self._device.connection_status == "connected"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _reset_device(self) -> None:
|
||||||
|
"""
|
||||||
|
Полностью уничтожить текущий BLE transport.
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
tion-btle не вызывает BleakClient.disconnect(),
|
||||||
|
если WinRT уже считает устройство disconnected.
|
||||||
|
|
||||||
|
Поэтому при полном reset принудительно закрываем
|
||||||
|
BleakClient напрямую.
|
||||||
|
"""
|
||||||
|
|
||||||
|
old_device = self._device
|
||||||
|
|
||||||
|
# Controller сразу больше не считает старый объект рабочим.
|
||||||
|
self._device = None
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
if old_device is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Нам нужен именно настоящий BleakClient.disconnect().
|
||||||
|
#
|
||||||
|
# Не old_device.disconnect(), потому что tion-btle
|
||||||
|
# может пропустить физический cleanup при
|
||||||
|
# connection_status == "disc".
|
||||||
|
await old_device._btle.disconnect()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# Старый transport всё равно больше использоваться
|
||||||
|
# не будет.
|
||||||
|
pass
|
||||||
|
|
||||||
|
"""
|
||||||
|
Соединение
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
async with self._lock:
|
||||||
|
if self.connected:
|
||||||
|
self._started = True
|
||||||
|
return
|
||||||
|
|
||||||
|
# Полностью закрываем всё, что осталось
|
||||||
|
# от предыдущего соединения.
|
||||||
|
await self._reset_device()
|
||||||
|
|
||||||
|
# Получаем свежий BLEDevice.
|
||||||
|
ble_device = await BleakScanner.find_device_by_address(
|
||||||
|
self._mac,
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ble_device is None:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Tion {self._mac} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Новый TionS4 = новый BleakClient.
|
||||||
|
device = TionS4(ble_device)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await device.connect()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# ВАЖНО:
|
||||||
|
# освобождаем даже частично созданную
|
||||||
|
# WinRT/GATT-сессию.
|
||||||
|
try:
|
||||||
|
await device._btle.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise
|
||||||
|
|
||||||
|
self._device = device
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Закрыть BLE-соединение.
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
await self._reset_device()
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Состояние
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_state(self) -> TionState:
|
||||||
|
"""
|
||||||
|
Получить реальное текущее состояние бризера.
|
||||||
|
"""
|
||||||
|
self._ensure_started()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
raw_state = await self._device.get()
|
||||||
|
|
||||||
|
state = TionState.from_raw(raw_state)
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
"""
|
||||||
|
Питание
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def power_on(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"state": "on"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def power_off(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"state": "off"
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Скорость вентилятора
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int) -> TionState:
|
||||||
|
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
|
||||||
|
raise ValueError("Tion fan speed must be between 1 and 6")
|
||||||
|
|
||||||
|
return await self._set({
|
||||||
|
"fan_speed": speed
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Целевая температура
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_target_temperature(self, temperature: int) -> TionState:
|
||||||
|
if not MIN_TARGET_TEMP <= temperature <= MAX_TARGET_TEMP:
|
||||||
|
raise ValueError(
|
||||||
|
f"Tion target temperature must be between "
|
||||||
|
f"{MIN_TARGET_TEMP} and {MAX_TARGET_TEMP} °C"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._set({
|
||||||
|
"heater_temp": temperature
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Нагрев
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def heater_on(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"heater": "on"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def heater_off(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"heater": "off"
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Забор воздуха
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def set_air_mode(self, mode: str) -> TionState:
|
||||||
|
if mode not in SUPPORTED_AIR_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported Tion air mode: {mode}. "
|
||||||
|
f"Available modes: {sorted(SUPPORTED_AIR_MODES)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._set({
|
||||||
|
"mode": mode
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Звук
|
||||||
|
"""
|
||||||
|
async def sound_on(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"sound": "on"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def sound_off(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"sound": "off"
|
||||||
|
})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Световая индикация
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def light_on(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"light": "on"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def light_off(self) -> TionState:
|
||||||
|
return await self._set({
|
||||||
|
"light": "off"
|
||||||
|
})
|
||||||
|
|
||||||
|
async def _set(self, settings: dict[str, Any]) -> TionState:
|
||||||
|
"""
|
||||||
|
Отправить настройки в Tion и затем прочитать
|
||||||
|
фактическое состояние устройства.
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._ensure_started()
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
await self._device.set(settings)
|
||||||
|
|
||||||
|
raw_state = await self._device.get()
|
||||||
|
|
||||||
|
state = TionState.from_raw(raw_state)
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _ensure_started(self) -> None:
|
||||||
|
if not self._started:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TionController is not connected. "
|
||||||
|
"Call await controller.connect() first."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "TionController":
|
||||||
|
await self.connect()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_value, traceback) -> None:
|
||||||
|
await self.disconnect()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def _is_on(value: Any) -> bool:
|
||||||
|
return value == "on"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TionState:
|
||||||
|
power: bool
|
||||||
|
heater: bool
|
||||||
|
heating: bool
|
||||||
|
sound: bool
|
||||||
|
mode: str
|
||||||
|
|
||||||
|
out_temp: int
|
||||||
|
in_temp: int
|
||||||
|
target_temp: int
|
||||||
|
|
||||||
|
fan_speed: int
|
||||||
|
filter_remain: float
|
||||||
|
|
||||||
|
device_time: str
|
||||||
|
request_error_code: int
|
||||||
|
model: str
|
||||||
|
|
||||||
|
light: bool | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_raw(cls, data: Mapping[str, Any]) -> "TionState":
|
||||||
|
light_raw = data.get("light")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
power=_is_on(data["state"]),
|
||||||
|
heater=_is_on(data["heater"]),
|
||||||
|
heating=_is_on(data["heating"]),
|
||||||
|
sound=_is_on(data["sound"]),
|
||||||
|
mode=str(data["mode"]),
|
||||||
|
|
||||||
|
out_temp=int(data["out_temp"]),
|
||||||
|
in_temp=int(data["in_temp"]),
|
||||||
|
target_temp=int(data["heater_temp"]),
|
||||||
|
|
||||||
|
fan_speed=int(data["fan_speed"]),
|
||||||
|
filter_remain=float(data["filter_remain"]),
|
||||||
|
|
||||||
|
device_time=str(data["time"]),
|
||||||
|
request_error_code=int(data["request_error_code"]),
|
||||||
|
model=str(data["model"]),
|
||||||
|
|
||||||
|
light=None if light_raw is None else _is_on(light_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return asdict(self)
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .controller import TionController
|
||||||
|
from .models import TionState
|
||||||
|
|
||||||
|
|
||||||
|
TionOperation = Callable[
|
||||||
|
[TionController],
|
||||||
|
Awaitable[TionState]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TionService:
|
||||||
|
"""
|
||||||
|
Долгоживущий сервис работы с Tion.
|
||||||
|
|
||||||
|
Отвечает за:
|
||||||
|
- подключение;
|
||||||
|
- периодический опрос состояния;
|
||||||
|
- online/offline;
|
||||||
|
- last_seen;
|
||||||
|
- автоматическое переподключение;
|
||||||
|
- синхронизацию команд с polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
controller: TionController,
|
||||||
|
poll_interval: float = 5.0,
|
||||||
|
):
|
||||||
|
if poll_interval <= 0:
|
||||||
|
raise ValueError("poll_interval must be greater than 0")
|
||||||
|
|
||||||
|
self._controller = controller
|
||||||
|
self._poll_interval = poll_interval
|
||||||
|
|
||||||
|
self._state: TionState | None = None
|
||||||
|
self._online = False
|
||||||
|
self._last_seen: datetime | None = None
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._poll_task: asyncio.Task | None = None
|
||||||
|
|
||||||
|
# Защищает последовательность:
|
||||||
|
#
|
||||||
|
# reconnect -> command -> update state
|
||||||
|
#
|
||||||
|
# от вмешательства polling или другой команды.
|
||||||
|
self._operation_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# Защищает start / stop.
|
||||||
|
self._lifecycle_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Properties
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> TionState | None:
|
||||||
|
"""
|
||||||
|
Последнее успешно полученное состояние Tion.
|
||||||
|
|
||||||
|
Bluetooth-запрос не выполняется.
|
||||||
|
"""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online(self) -> bool:
|
||||||
|
return self._online
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_seen(self) -> datetime | None:
|
||||||
|
"""
|
||||||
|
Время последнего успешного обмена с Tion.
|
||||||
|
"""
|
||||||
|
return self._last_seen
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
"""
|
||||||
|
Последняя ошибка связи.
|
||||||
|
|
||||||
|
После успешного обмена сбрасывается в None.
|
||||||
|
"""
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Запустить сервис.
|
||||||
|
|
||||||
|
Первая попытка подключения и чтения состояния выполняется сразу.
|
||||||
|
После этого запускается фоновый polling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# Сразу пытаемся получить состояние.
|
||||||
|
# Если Tion недоступен, сервис всё равно продолжит работу.
|
||||||
|
await self.refresh_state()
|
||||||
|
|
||||||
|
self._poll_task = asyncio.create_task(
|
||||||
|
self._poll_loop(),
|
||||||
|
name="tion-poll",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""
|
||||||
|
Остановить polling и корректно закрыть BLE-соединение.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with self._lifecycle_lock:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
poll_task = self._poll_task
|
||||||
|
self._poll_task = None
|
||||||
|
|
||||||
|
if poll_task is not None:
|
||||||
|
poll_task.cancel()
|
||||||
|
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await poll_task
|
||||||
|
|
||||||
|
async with self._operation_lock:
|
||||||
|
await self._safe_disconnect()
|
||||||
|
|
||||||
|
self._online = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def refresh_state(self) -> TionState | None:
|
||||||
|
"""
|
||||||
|
Принудительно обновить состояние Tion.
|
||||||
|
|
||||||
|
При ошибке:
|
||||||
|
- online становится False;
|
||||||
|
- last_error обновляется;
|
||||||
|
- старый state сохраняется;
|
||||||
|
- исключение наружу не выбрасывается.
|
||||||
|
|
||||||
|
Возвращает None при ошибке.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with self._operation_lock:
|
||||||
|
return await self._execute_locked(
|
||||||
|
lambda controller: controller.get_state(),
|
||||||
|
raise_on_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Commands
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation: TionOperation,
|
||||||
|
) -> TionState:
|
||||||
|
"""
|
||||||
|
Выполнить любую команду TionController.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
await service.execute(
|
||||||
|
lambda tion: tion.set_speed(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
После команды состояние Service автоматически обновляется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async with self._operation_lock:
|
||||||
|
state = await self._execute_locked(
|
||||||
|
operation,
|
||||||
|
raise_on_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Здесь None невозможен, потому что raise_on_error=True.
|
||||||
|
assert state is not None
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _execute_locked(
|
||||||
|
self,
|
||||||
|
operation: TionOperation,
|
||||||
|
*,
|
||||||
|
raise_on_error: bool,
|
||||||
|
) -> TionState | None:
|
||||||
|
"""
|
||||||
|
Выполнение BLE-операции.
|
||||||
|
|
||||||
|
Вызывается только при занятом _operation_lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._ensure_connected()
|
||||||
|
|
||||||
|
state = await operation(self._controller)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._mark_offline(exc)
|
||||||
|
|
||||||
|
# После BLE-ошибки считаем соединение повреждённым.
|
||||||
|
# На следующей попытке будет создано новое.
|
||||||
|
await self._safe_disconnect()
|
||||||
|
|
||||||
|
if raise_on_error:
|
||||||
|
raise
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._mark_online(state)
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def _ensure_connected(self) -> None:
|
||||||
|
"""
|
||||||
|
Убедиться, что имеется рабочее BLE-соединение.
|
||||||
|
|
||||||
|
Если физического соединения нет, старое состояние подключения
|
||||||
|
сбрасывается и выполняется новое connect().
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self._controller.connected:
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._safe_disconnect()
|
||||||
|
await self._controller.connect()
|
||||||
|
|
||||||
|
async def _safe_disconnect(self) -> None:
|
||||||
|
"""
|
||||||
|
Закрыть соединение, не распространяя ошибку disconnect наружу.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
await self._controller.disconnect()
|
||||||
|
|
||||||
|
def _mark_online(self, state: TionState) -> None:
|
||||||
|
self._state = state
|
||||||
|
self._online = True
|
||||||
|
self._last_seen = datetime.now(timezone.utc)
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
def _mark_offline(self, exc: Exception) -> None:
|
||||||
|
self._online = False
|
||||||
|
self._last_error = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Background polling
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""
|
||||||
|
Фоновый цикл обновления состояния.
|
||||||
|
"""
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(self._poll_interval)
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.refresh_state()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Context manager
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "TionService":
|
||||||
|
await self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(
|
||||||
|
self,
|
||||||
|
exc_type,
|
||||||
|
exc_value,
|
||||||
|
traceback,
|
||||||
|
) -> None:
|
||||||
|
await self.stop()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 700
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
- ppm: 900
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- ppm: 1300
|
||||||
|
speed: 4
|
||||||
|
|
||||||
|
- ppm: 1600
|
||||||
|
speed: 5
|
||||||
|
|
||||||
|
- ppm: 2000
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
workday:
|
||||||
|
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
power: on
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
heater: on
|
||||||
|
target_temp: 21
|
||||||
|
|
||||||
|
- time: "09:30"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
target_temp: 25
|
||||||
|
|
||||||
|
- time: "22:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
- time: "22:45"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
weekend:
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
power: on
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
heater: on
|
||||||
|
target_temp: 21
|
||||||
|
|
||||||
|
- time: "10:30"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 2
|
||||||
|
target_temp: 25
|
||||||
|
|
||||||
|
- time: "19:02"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
- time: "22:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 6
|
||||||
|
|
||||||
|
- time: "22:45"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
days:
|
||||||
|
|
||||||
|
mon: workday
|
||||||
|
tue: workday
|
||||||
|
wed: workday
|
||||||
|
thu: workday
|
||||||
|
fri: workday
|
||||||
|
|
||||||
|
sat: weekend
|
||||||
|
sun: weekend
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
tion-btle==3.3.6
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
PyYAML
|
||||||
|
paho-mqtt>=2.1,<3
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from .loader import load_schedule
|
||||||
|
from .models import (
|
||||||
|
ScheduleAction,
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
ScheduleOccurrence,
|
||||||
|
SchedulePoint,
|
||||||
|
ScheduleResolution,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
from .service import ScheduleService
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"load_schedule",
|
||||||
|
"ScheduleAction",
|
||||||
|
"ScheduleActionType",
|
||||||
|
"ScheduleConfig",
|
||||||
|
"ScheduleOccurrence",
|
||||||
|
"SchedulePoint",
|
||||||
|
"ScheduleResolution",
|
||||||
|
"ScheduledSettings",
|
||||||
|
"ScheduleService",
|
||||||
|
]
|
||||||
|
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
from datetime import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
MIN_FAN_SPEED,
|
||||||
|
MAX_FAN_SPEED,
|
||||||
|
MIN_TARGET_TEMP,
|
||||||
|
MAX_TARGET_TEMP,
|
||||||
|
SUPPORTED_AIR_MODES,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ScheduleAction,
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
SchedulePoint,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WEEKDAYS = (
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SET_FIELDS = {
|
||||||
|
"power",
|
||||||
|
"speed",
|
||||||
|
"heater",
|
||||||
|
"target_temp",
|
||||||
|
"mode",
|
||||||
|
"sound",
|
||||||
|
"light",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time(value: Any) -> time:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError(
|
||||||
|
f"Schedule time must be a string: {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = time.fromisoformat(value)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid schedule time: {value!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Не разрешаем секунды.
|
||||||
|
if parsed.second != 0 or parsed.microsecond != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"Schedule time must use HH:MM: {value!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_on_off(
|
||||||
|
name: str,
|
||||||
|
value: Any,
|
||||||
|
) -> bool:
|
||||||
|
# PyYAML может автоматически превратить
|
||||||
|
# on/off в True/False.
|
||||||
|
if type(value) is bool:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
|
||||||
|
if normalized == "on":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if normalized == "off":
|
||||||
|
return False
|
||||||
|
|
||||||
|
raise ValueError(
|
||||||
|
f"{name} must be 'on' or 'off'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_settings(
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> ScheduledSettings:
|
||||||
|
|
||||||
|
unknown = set(data) - SET_FIELDS
|
||||||
|
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown SET fields: {sorted(unknown)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if "power" in data:
|
||||||
|
values["power"] = _parse_on_off(
|
||||||
|
"power",
|
||||||
|
data["power"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" in data:
|
||||||
|
speed = data["speed"]
|
||||||
|
|
||||||
|
if type(speed) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"speed must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not MIN_FAN_SPEED <= speed <= MAX_FAN_SPEED:
|
||||||
|
raise ValueError(
|
||||||
|
f"speed must be between "
|
||||||
|
f"{MIN_FAN_SPEED} and {MAX_FAN_SPEED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["speed"] = speed
|
||||||
|
|
||||||
|
if "heater" in data:
|
||||||
|
values["heater"] = _parse_on_off(
|
||||||
|
"heater",
|
||||||
|
data["heater"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "target_temp" in data:
|
||||||
|
temperature = data["target_temp"]
|
||||||
|
|
||||||
|
if type(temperature) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"target_temp must be an integer"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
MIN_TARGET_TEMP
|
||||||
|
<= temperature
|
||||||
|
<= MAX_TARGET_TEMP
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"target_temp must be between "
|
||||||
|
f"{MIN_TARGET_TEMP} and "
|
||||||
|
f"{MAX_TARGET_TEMP}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["target_temp"] = temperature
|
||||||
|
|
||||||
|
if "mode" in data:
|
||||||
|
mode = data["mode"]
|
||||||
|
|
||||||
|
if mode not in SUPPORTED_AIR_MODES:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported air mode: {mode!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
values["mode"] = mode
|
||||||
|
|
||||||
|
if "sound" in data:
|
||||||
|
values["sound"] = _parse_on_off(
|
||||||
|
"sound",
|
||||||
|
data["sound"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "light" in data:
|
||||||
|
values["light"] = _parse_on_off(
|
||||||
|
"light",
|
||||||
|
data["light"],
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = ScheduledSettings(**values)
|
||||||
|
|
||||||
|
if settings.empty:
|
||||||
|
raise ValueError(
|
||||||
|
"SET action must contain "
|
||||||
|
"at least one setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_action(
|
||||||
|
data: Any,
|
||||||
|
) -> ScheduleAction:
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"Schedule action must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
action_type_raw = data.get("type")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action_type = ScheduleActionType(action_type_raw)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported action type: "
|
||||||
|
f"{action_type_raw!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if action_type == ScheduleActionType.AUTO:
|
||||||
|
|
||||||
|
allowed_fields = {
|
||||||
|
"type",
|
||||||
|
"speed",
|
||||||
|
"target_temp",
|
||||||
|
}
|
||||||
|
|
||||||
|
extra = set(data) - allowed_fields
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO action supports only "
|
||||||
|
"'speed' and 'target_temp': "
|
||||||
|
f"{sorted(extra)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "speed" not in data:
|
||||||
|
raise ValueError(
|
||||||
|
"AUTO action requires 'speed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_data = {
|
||||||
|
"speed": data["speed"],
|
||||||
|
}
|
||||||
|
|
||||||
|
if "target_temp" in data:
|
||||||
|
settings_data["target_temp"] = (
|
||||||
|
data["target_temp"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduleAction(
|
||||||
|
type=ScheduleActionType.AUTO,
|
||||||
|
settings=_parse_settings(settings_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_data = {
|
||||||
|
key: value
|
||||||
|
for key, value in data.items()
|
||||||
|
if key != "type"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScheduleAction(
|
||||||
|
type=ScheduleActionType.SET,
|
||||||
|
settings=_parse_settings(
|
||||||
|
settings_data
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_template(
|
||||||
|
name: str,
|
||||||
|
data: Any,
|
||||||
|
) -> tuple[SchedulePoint, ...]:
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
raise ValueError(
|
||||||
|
f"Template {name!r} must be a list"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ValueError(
|
||||||
|
f"Template {name!r} cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
points: list[SchedulePoint] = []
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid point in template {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "time" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"Point in {name!r} has no time"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "action" not in item:
|
||||||
|
raise ValueError(
|
||||||
|
f"Point in {name!r} has no action"
|
||||||
|
)
|
||||||
|
|
||||||
|
points.append(
|
||||||
|
SchedulePoint(
|
||||||
|
at=_parse_time(item["time"]),
|
||||||
|
action=_parse_action(
|
||||||
|
item["action"]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
points.sort(
|
||||||
|
key=lambda point: point.at
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_times = set()
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
if point.at in seen_times:
|
||||||
|
raise ValueError(
|
||||||
|
f"Duplicate time {point.at} "
|
||||||
|
f"in template {name!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
seen_times.add(point.at)
|
||||||
|
|
||||||
|
return tuple(points)
|
||||||
|
|
||||||
|
|
||||||
|
def load_schedule(
|
||||||
|
path: str | Path,
|
||||||
|
) -> ScheduleConfig:
|
||||||
|
|
||||||
|
path = Path(path)
|
||||||
|
|
||||||
|
with path.open(
|
||||||
|
"r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
raw = yaml.safe_load(file)
|
||||||
|
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"Schedule root must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
version = raw.get("version", 1)
|
||||||
|
|
||||||
|
if version != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported schedule version: {version}"
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled = raw.get("enabled", True)
|
||||||
|
|
||||||
|
if type(enabled) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
"enabled must be true or false"
|
||||||
|
)
|
||||||
|
|
||||||
|
timezone = raw.get(
|
||||||
|
"timezone",
|
||||||
|
"local",
|
||||||
|
)
|
||||||
|
|
||||||
|
if timezone != "local":
|
||||||
|
raise ValueError(
|
||||||
|
"Only timezone: local "
|
||||||
|
"is currently supported"
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_templates = raw.get("templates")
|
||||||
|
|
||||||
|
if not isinstance(raw_templates, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"templates must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = {
|
||||||
|
name: _parse_template(
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
)
|
||||||
|
for name, template
|
||||||
|
in raw_templates.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_days = raw.get("days")
|
||||||
|
|
||||||
|
if not isinstance(raw_days, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"days must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
days: dict[str, str] = {}
|
||||||
|
|
||||||
|
for weekday in WEEKDAYS:
|
||||||
|
|
||||||
|
if weekday not in raw_days:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing schedule day: {weekday}"
|
||||||
|
)
|
||||||
|
|
||||||
|
template_name = raw_days[weekday]
|
||||||
|
|
||||||
|
if template_name not in templates:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown template "
|
||||||
|
f"{template_name!r} "
|
||||||
|
f"for {weekday}"
|
||||||
|
)
|
||||||
|
|
||||||
|
days[weekday] = template_name
|
||||||
|
|
||||||
|
unknown_days = (
|
||||||
|
set(raw_days)
|
||||||
|
- set(WEEKDAYS)
|
||||||
|
)
|
||||||
|
|
||||||
|
if unknown_days:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown weekdays: "
|
||||||
|
f"{sorted(unknown_days)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduleConfig(
|
||||||
|
version=version,
|
||||||
|
enabled=enabled,
|
||||||
|
timezone=timezone,
|
||||||
|
templates=templates,
|
||||||
|
days=days,
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from dataclasses import dataclass, field, fields
|
||||||
|
from datetime import datetime, time
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleActionType(StrEnum):
|
||||||
|
SET = "set"
|
||||||
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduledSettings:
|
||||||
|
power: bool | None = None
|
||||||
|
speed: int | None = None
|
||||||
|
heater: bool | None = None
|
||||||
|
target_temp: int | None = None
|
||||||
|
mode: str | None = None
|
||||||
|
sound: bool | None = None
|
||||||
|
light: bool | None = None
|
||||||
|
|
||||||
|
def merged(self, newer: "ScheduledSettings") -> "ScheduledSettings":
|
||||||
|
"""
|
||||||
|
Наложить более новые настройки поверх старых.
|
||||||
|
|
||||||
|
None означает:
|
||||||
|
параметр в данной точке расписания не менялся.
|
||||||
|
"""
|
||||||
|
|
||||||
|
values: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for item in fields(self):
|
||||||
|
old_value = getattr(self, item.name)
|
||||||
|
new_value = getattr(newer, item.name)
|
||||||
|
|
||||||
|
values[item.name] = (
|
||||||
|
old_value
|
||||||
|
if new_value is None
|
||||||
|
else new_value
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScheduledSettings(**values)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def empty(self) -> bool:
|
||||||
|
return all(
|
||||||
|
getattr(self, item.name) is None
|
||||||
|
for item in fields(self)
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
skip_none: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for item in fields(self):
|
||||||
|
value = getattr(self, item.name)
|
||||||
|
|
||||||
|
if skip_none and value is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
result[item.name] = value
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleAction:
|
||||||
|
type: ScheduleActionType
|
||||||
|
|
||||||
|
settings: ScheduledSettings = field(
|
||||||
|
default_factory=ScheduledSettings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SchedulePoint:
|
||||||
|
at: time
|
||||||
|
action: ScheduleAction
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleConfig:
|
||||||
|
version: int
|
||||||
|
enabled: bool
|
||||||
|
timezone: str
|
||||||
|
|
||||||
|
templates: dict[
|
||||||
|
str,
|
||||||
|
tuple[SchedulePoint, ...]
|
||||||
|
]
|
||||||
|
|
||||||
|
days: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleOccurrence:
|
||||||
|
"""
|
||||||
|
Конкретная точка расписания уже с датой.
|
||||||
|
"""
|
||||||
|
|
||||||
|
when: datetime
|
||||||
|
weekday: str
|
||||||
|
template: str
|
||||||
|
point: SchedulePoint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScheduleResolution:
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
current: ScheduleOccurrence | None
|
||||||
|
next: ScheduleOccurrence | None
|
||||||
|
|
||||||
|
scheduled_settings: ScheduledSettings
|
||||||
|
|
||||||
|
auto_active: bool
|
||||||
|
auto_fallback_speed: int | None
|
||||||
|
auto_target_temp: int | None = None
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from app.tion.service import TionService
|
||||||
|
|
||||||
|
from datetime import (
|
||||||
|
date,
|
||||||
|
datetime,
|
||||||
|
timedelta,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleConfig,
|
||||||
|
ScheduleOccurrence,
|
||||||
|
ScheduleResolution,
|
||||||
|
ScheduledSettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
WEEKDAYS = (
|
||||||
|
"mon",
|
||||||
|
"tue",
|
||||||
|
"wed",
|
||||||
|
"thu",
|
||||||
|
"fri",
|
||||||
|
"sat",
|
||||||
|
"sun",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config,
|
||||||
|
tion: TionService | None = None,
|
||||||
|
check_interval: float = 5.0,
|
||||||
|
state_path: str | Path | None = None,
|
||||||
|
):
|
||||||
|
self._config = config
|
||||||
|
self._tion = tion
|
||||||
|
self._check_interval = check_interval
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
# Последняя успешно применённая точка расписания.
|
||||||
|
self._last_applied_when: datetime | None = None
|
||||||
|
|
||||||
|
# Temporary override.
|
||||||
|
self._override_settings = ScheduledSettings()
|
||||||
|
self._override_until: datetime | None = None
|
||||||
|
|
||||||
|
# True означает:
|
||||||
|
# override записан, но применить его к Tion
|
||||||
|
# пока не удалось.
|
||||||
|
self._override_pending = False
|
||||||
|
|
||||||
|
# Ошибка именно ScheduleService.
|
||||||
|
self._last_error: str | None = None
|
||||||
|
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
self._state_path = (
|
||||||
|
Path(state_path)
|
||||||
|
if state_path is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self._load_runtime_state()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def paused(self) -> bool:
|
||||||
|
return self._paused
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str | None:
|
||||||
|
return self._last_error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_settings(self) -> ScheduledSettings:
|
||||||
|
return self._override_settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_until(self) -> datetime | None:
|
||||||
|
return self._override_until
|
||||||
|
|
||||||
|
@property
|
||||||
|
def override_active(self) -> bool:
|
||||||
|
if self._override_until is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return datetime.now() < self._override_until
|
||||||
|
|
||||||
|
@property
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._state_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(
|
||||||
|
self._state_path.read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (
|
||||||
|
OSError,
|
||||||
|
json.JSONDecodeError,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
paused = data.get("paused")
|
||||||
|
|
||||||
|
if isinstance(paused, bool):
|
||||||
|
self._paused = paused
|
||||||
|
|
||||||
|
def _save_runtime_state(self) -> None:
|
||||||
|
|
||||||
|
if self._state_path is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"paused": self._paused,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._state_path.parent.mkdir(
|
||||||
|
parents=True,
|
||||||
|
exist_ok=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path = (
|
||||||
|
self._state_path.with_suffix(
|
||||||
|
self._state_path.suffix + ".tmp"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
data,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
temp_path.replace(
|
||||||
|
self._state_path
|
||||||
|
)
|
||||||
|
|
||||||
|
async def apply_override(self, settings: ScheduledSettings) -> None:
|
||||||
|
|
||||||
|
if settings.empty:
|
||||||
|
raise ValueError(
|
||||||
|
"Override settings cannot be empty"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TionService is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self.resolve(now)
|
||||||
|
|
||||||
|
if not resolution.enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Schedule is disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolution.current is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Current schedule point not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resolution.next is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Next schedule point not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если старый override уже закончился,
|
||||||
|
# перед созданием нового очищаем его.
|
||||||
|
if ( self._override_until is not None
|
||||||
|
and now >= self._override_until
|
||||||
|
):
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# Добавляем новые ручные параметры
|
||||||
|
# к уже существующему override.
|
||||||
|
self._override_settings = ( self._override_settings.merged(settings) )
|
||||||
|
|
||||||
|
# Override действует строго до
|
||||||
|
# следующей точки расписания.
|
||||||
|
self._override_until = resolution.next.when
|
||||||
|
|
||||||
|
# Считаем его неприменённым,
|
||||||
|
# пока команда реально не прошла.
|
||||||
|
self._override_pending = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = (
|
||||||
|
f"{type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override остаётся сохранённым.
|
||||||
|
# Фоновый цикл попробует ещё раз.
|
||||||
|
raise
|
||||||
|
|
||||||
|
else:
|
||||||
|
self._override_pending = False
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
def _clear_override(self) -> None:
|
||||||
|
self._override_settings = ScheduledSettings()
|
||||||
|
self._override_until = None
|
||||||
|
self._override_pending = False
|
||||||
|
|
||||||
|
async def clear_override(self) -> None:
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# Сразу пересчитываем и применяем текущее состояние расписания.
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
|
||||||
|
async def pause(self) -> None:
|
||||||
|
|
||||||
|
if self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Сначала запрещаем расписанию вмешиваться.
|
||||||
|
self._paused = True
|
||||||
|
|
||||||
|
# Temporary override больше не имеет смысла:
|
||||||
|
# мы переходим в полноценный ручной режим.
|
||||||
|
self._clear_override()
|
||||||
|
self._last_applied_when = None
|
||||||
|
|
||||||
|
self._save_runtime_state()
|
||||||
|
|
||||||
|
async def resume(self) -> None:
|
||||||
|
|
||||||
|
if not self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._paused = False
|
||||||
|
|
||||||
|
self._save_runtime_state()
|
||||||
|
|
||||||
|
# Сразу применяем состояние расписания,
|
||||||
|
# актуальное именно сейчас.
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
self,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> ScheduleResolution:
|
||||||
|
"""
|
||||||
|
Определить:
|
||||||
|
- текущую точку;
|
||||||
|
- следующую точку;
|
||||||
|
- накопленные SET-настройки;
|
||||||
|
- активен ли сейчас AUTO.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if not self._config.enabled:
|
||||||
|
return ScheduleResolution(
|
||||||
|
enabled=False,
|
||||||
|
current=None,
|
||||||
|
next=None,
|
||||||
|
scheduled_settings=ScheduledSettings(),
|
||||||
|
auto_active=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Недели назад достаточно,
|
||||||
|
# поскольку расписание повторяется каждые 7 дней.
|
||||||
|
start_date = now.date() - timedelta(days=7)
|
||||||
|
|
||||||
|
end_date = now.date() + timedelta(days=7)
|
||||||
|
|
||||||
|
occurrences = self._build_occurrences(
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
current = None
|
||||||
|
next_point = None
|
||||||
|
|
||||||
|
for occurrence in occurrences:
|
||||||
|
|
||||||
|
if occurrence.when <= now:
|
||||||
|
current = occurrence
|
||||||
|
continue
|
||||||
|
|
||||||
|
next_point = occurrence
|
||||||
|
break
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Could not determine current "
|
||||||
|
"schedule point"
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_settings = (
|
||||||
|
self._calculate_settings(
|
||||||
|
occurrences,
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
auto_active = current.point.action.type == ScheduleActionType.AUTO
|
||||||
|
|
||||||
|
auto_fallback_speed = None
|
||||||
|
auto_target_temp = None
|
||||||
|
|
||||||
|
if auto_active:
|
||||||
|
auto_fallback_speed = current.point.action.settings.speed
|
||||||
|
auto_target_temp = current.point.action.settings.target_temp
|
||||||
|
|
||||||
|
return ScheduleResolution(
|
||||||
|
enabled=True,
|
||||||
|
current=current,
|
||||||
|
next=next_point,
|
||||||
|
scheduled_settings=scheduled_settings,
|
||||||
|
auto_active=auto_active,
|
||||||
|
auto_fallback_speed=auto_fallback_speed,
|
||||||
|
auto_target_temp=auto_target_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_occurrences(
|
||||||
|
self,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
) -> list[ScheduleOccurrence]:
|
||||||
|
|
||||||
|
result: list[ScheduleOccurrence] = []
|
||||||
|
|
||||||
|
current_date = start_date
|
||||||
|
|
||||||
|
while current_date <= end_date:
|
||||||
|
|
||||||
|
weekday = WEEKDAYS[
|
||||||
|
current_date.weekday()
|
||||||
|
]
|
||||||
|
|
||||||
|
template_name = (
|
||||||
|
self._config.days[weekday]
|
||||||
|
)
|
||||||
|
|
||||||
|
template = (
|
||||||
|
self._config.templates[
|
||||||
|
template_name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for point in template:
|
||||||
|
|
||||||
|
when = datetime.combine(
|
||||||
|
current_date,
|
||||||
|
point.at,
|
||||||
|
)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
ScheduleOccurrence(
|
||||||
|
when=when,
|
||||||
|
weekday=weekday,
|
||||||
|
template=template_name,
|
||||||
|
point=point,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
current_date += timedelta(days=1)
|
||||||
|
|
||||||
|
result.sort(
|
||||||
|
key=lambda occurrence:
|
||||||
|
occurrence.when
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _calculate_settings(
|
||||||
|
self,
|
||||||
|
occurrences: list[
|
||||||
|
ScheduleOccurrence
|
||||||
|
],
|
||||||
|
current: ScheduleOccurrence,
|
||||||
|
) -> ScheduledSettings:
|
||||||
|
"""
|
||||||
|
Восстановить последние SET-значения
|
||||||
|
каждого параметра расписания.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = ScheduledSettings()
|
||||||
|
|
||||||
|
for occurrence in occurrences:
|
||||||
|
|
||||||
|
if occurrence.when > current.when:
|
||||||
|
break
|
||||||
|
|
||||||
|
action = occurrence.point.action
|
||||||
|
|
||||||
|
if (
|
||||||
|
action.type
|
||||||
|
!= ScheduleActionType.SET
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
settings = settings.merged(
|
||||||
|
action.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
raise RuntimeError("TionService is required to start schedule")
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
# При запуске сразу приводим Tion
|
||||||
|
# к текущему состоянию расписания.
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._task = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
while self._running:
|
||||||
|
await asyncio.sleep(
|
||||||
|
self._check_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._process()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _process(self) -> None:
|
||||||
|
#Если ручное управление то сразу выходим
|
||||||
|
if self._paused:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
resolution = self.resolve(now)
|
||||||
|
|
||||||
|
if not resolution.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
current = resolution.current
|
||||||
|
|
||||||
|
if current is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 1. Проверяем окончание temporary override
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._override_until is not None
|
||||||
|
and now >= self._override_until
|
||||||
|
):
|
||||||
|
self._clear_override()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 2. Пока override действует,
|
||||||
|
# расписание Tion не трогает
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self.override_active:
|
||||||
|
|
||||||
|
# Если override ранее не удалось применить,
|
||||||
|
# пробуем снова.
|
||||||
|
if self._override_pending:
|
||||||
|
await self._apply_settings(self._override_settings)
|
||||||
|
self._override_pending = False
|
||||||
|
self._last_error = None
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 3. Если текущая точка уже успешно применена,
|
||||||
|
# ничего не делаем
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
if self._last_applied_when == current.when:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 4. Применяем состояние расписания
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
settings = resolution.scheduled_settings
|
||||||
|
|
||||||
|
if resolution.auto_active:
|
||||||
|
# В режиме AUTO скорость принадлежит
|
||||||
|
# AutoController.
|
||||||
|
#
|
||||||
|
# Остальные параметры расписания
|
||||||
|
# (power, heater, temperature, mode...)
|
||||||
|
# должны продолжать работать.
|
||||||
|
settings = replace(
|
||||||
|
settings,
|
||||||
|
speed=None,
|
||||||
|
heater=None,
|
||||||
|
target_temp=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._apply_settings(settings)
|
||||||
|
|
||||||
|
# Ставим только ПОСЛЕ успешного применения.
|
||||||
|
self._last_applied_when = current.when
|
||||||
|
self._last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _apply_settings(self, settings: ScheduledSettings) -> None:
|
||||||
|
|
||||||
|
if self._tion is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Если нужно включить Tion —
|
||||||
|
# сначала включаем.
|
||||||
|
if settings.power is True:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.power_on()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.mode is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_air_mode(settings.mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.target_temp is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_target_temperature(
|
||||||
|
settings.target_temp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.heater is not None:
|
||||||
|
if settings.heater:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.heater_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.heater_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.speed is not None:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion:
|
||||||
|
tion.set_speed(settings.speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.sound is not None:
|
||||||
|
if settings.sound:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.sound_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.sound_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.light is not None:
|
||||||
|
if settings.light:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.light_on()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.light_off()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если нужно выключить —
|
||||||
|
# выключаем последним.
|
||||||
|
if settings.power is False:
|
||||||
|
await self._tion.execute(
|
||||||
|
lambda tion: tion.power_off()
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
|
||||||
|
# Убираем служебное логирование библиотек
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
service = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
for seconds in range(0, 91, 3):
|
||||||
|
print(
|
||||||
|
f"{seconds:02d}s | "
|
||||||
|
f"online={service.online} | "
|
||||||
|
f"last_seen={service.last_seen} | "
|
||||||
|
f"error={service.last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from bleak import BleakScanner
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("Ищу Tion...")
|
||||||
|
|
||||||
|
device = await BleakScanner.find_device_by_address(
|
||||||
|
TION_MAC,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if device is None:
|
||||||
|
print("Tion НЕ найден сканером")
|
||||||
|
else:
|
||||||
|
print("Tion найден:")
|
||||||
|
print(f" name: {device.name}")
|
||||||
|
print(f" address: {device.address}")
|
||||||
|
print(f" details: {device.details}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#d1749bebeea6
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from tion_btle import TionS4
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
def print_state(state: dict):
|
||||||
|
print(json.dumps(
|
||||||
|
state,
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
tion = TionS4(TION_MAC)
|
||||||
|
|
||||||
|
print("=== Подключение к Tion ===")
|
||||||
|
await tion.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Получаем исходное состояние
|
||||||
|
print("\n=== Исходное состояние ===")
|
||||||
|
state = await tion.get()
|
||||||
|
print_state(state)
|
||||||
|
|
||||||
|
# 2. Включаем Tion и устанавливаем скорость 2
|
||||||
|
print("\n=== Включаю Tion, скорость 2 ===")
|
||||||
|
|
||||||
|
await tion.set({
|
||||||
|
"state": "on",
|
||||||
|
"fan_speed": 2
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# 3. Повторно читаем реальное состояние
|
||||||
|
print("\n=== Состояние после команды ===")
|
||||||
|
state = await tion.get()
|
||||||
|
print_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
finally:
|
||||||
|
print("\n=== Отключение ===")
|
||||||
|
await tion.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = (
|
||||||
|
Path(__file__)
|
||||||
|
.resolve()
|
||||||
|
.parents[1]
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTO_CONFIG_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
config = load_auto_config(
|
||||||
|
AUTO_CONFIG_FILE
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("AUTO CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Version:",
|
||||||
|
config.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Check interval:",
|
||||||
|
config.check_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Base speed:",
|
||||||
|
config.co2.base_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Hysteresis:",
|
||||||
|
config.co2.hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Thresholds:"
|
||||||
|
)
|
||||||
|
|
||||||
|
for ppm, speed in (
|
||||||
|
config.co2.thresholds
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f" CO2 >= {ppm:<4} "
|
||||||
|
f"-> speed {speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config.version == 1
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.check_interval
|
||||||
|
== 5.0
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.base_speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.hysteresis
|
||||||
|
== 100
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.co2.thresholds
|
||||||
|
== (
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"AUTO CONFIG TEST PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,884 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.co2_policy import Co2SpeedPolicy
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake ScheduleService
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeScheduleService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
enabled: bool = True,
|
||||||
|
auto_active: bool = False,
|
||||||
|
fallback_speed: int | None = None,
|
||||||
|
):
|
||||||
|
self.enabled = enabled
|
||||||
|
self.auto_active = auto_active
|
||||||
|
self.fallback_speed = fallback_speed
|
||||||
|
|
||||||
|
self.override_active = False
|
||||||
|
|
||||||
|
def resolve(self, now):
|
||||||
|
return SimpleNamespace(
|
||||||
|
enabled=self.enabled,
|
||||||
|
auto_active=self.auto_active,
|
||||||
|
auto_fallback_speed=self.fallback_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake QingpingService
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
online: bool = False,
|
||||||
|
co2: int | None = None,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
co2=co2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fake Tion
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(
|
||||||
|
self,
|
||||||
|
speed: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def print_state(
|
||||||
|
title: str,
|
||||||
|
auto: AutoController,
|
||||||
|
tion: FakeTionService,
|
||||||
|
):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Auto status:",
|
||||||
|
auto.status(),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Tion calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Main test
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
schedule = FakeScheduleService()
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = Co2SpeedPolicy(
|
||||||
|
base_speed=1,
|
||||||
|
thresholds=(
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
),
|
||||||
|
hysteresis=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
auto = AutoController(
|
||||||
|
schedule_service=schedule,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 1
|
||||||
|
#
|
||||||
|
# AUTO не активен.
|
||||||
|
#
|
||||||
|
# AutoController не должен ничего делать.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.enabled = True
|
||||||
|
schedule.auto_active = False
|
||||||
|
schedule.fallback_speed = None
|
||||||
|
schedule.override_active = False
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 1200
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 1 — AUTO INACTIVE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 2
|
||||||
|
#
|
||||||
|
# AUTO активен.
|
||||||
|
# Qingping работает.
|
||||||
|
# CO2 = 700.
|
||||||
|
#
|
||||||
|
# Ожидаем speed 1.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.auto_active = True
|
||||||
|
schedule.fallback_speed = 2
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 700
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 2 — CO2 700",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 3
|
||||||
|
#
|
||||||
|
# CO2 вырос до 850.
|
||||||
|
#
|
||||||
|
# Ожидаем переход:
|
||||||
|
#
|
||||||
|
# speed 1 -> speed 2
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 850
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 3 — CO2 850",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 4
|
||||||
|
#
|
||||||
|
# CO2 вырос до 1050.
|
||||||
|
#
|
||||||
|
# Ожидаем:
|
||||||
|
#
|
||||||
|
# speed 2 -> speed 3
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 1050
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 4 — CO2 1050",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 5
|
||||||
|
#
|
||||||
|
# CO2 опустился до 950.
|
||||||
|
#
|
||||||
|
# Порог speed 3:
|
||||||
|
#
|
||||||
|
# вверх = 1000
|
||||||
|
# вниз = 900
|
||||||
|
#
|
||||||
|
# Поэтому остаёмся на speed 3.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 950
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 5 — HYSTERESIS CO2 950",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
# Новой команды быть не должно.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 6
|
||||||
|
#
|
||||||
|
# CO2 дошёл до 900.
|
||||||
|
#
|
||||||
|
# Теперь переходим:
|
||||||
|
#
|
||||||
|
# speed 3 -> speed 2
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 900
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 6 — CO2 900",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 7
|
||||||
|
#
|
||||||
|
# Qingping offline.
|
||||||
|
#
|
||||||
|
# AUTO должен перейти в fallback.
|
||||||
|
#
|
||||||
|
# fallback speed = 2
|
||||||
|
#
|
||||||
|
# Но Tion уже находится на speed 2,
|
||||||
|
# поэтому повторная команда не нужна.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 7 — QINGPING OFFLINE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "qingping_offline"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 8
|
||||||
|
#
|
||||||
|
# Qingping всё ещё offline.
|
||||||
|
#
|
||||||
|
# Одинаковую fallback-команду повторять нельзя.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 8 — FALLBACK NOT REPEATED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 9
|
||||||
|
#
|
||||||
|
# Qingping online,
|
||||||
|
# но CO2 отсутствует.
|
||||||
|
#
|
||||||
|
# Это тоже fallback.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = None
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 9 — CO2 MISSING",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "co2_missing"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 10
|
||||||
|
#
|
||||||
|
# Qingping восстановился.
|
||||||
|
#
|
||||||
|
# CO2 = 1700.
|
||||||
|
#
|
||||||
|
# После fallback auto_speed был сброшен,
|
||||||
|
# поэтому скорость выбирается заново.
|
||||||
|
#
|
||||||
|
# Ожидаем speed 5.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = True
|
||||||
|
qingping.state.co2 = 1700
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 10 — RECOVERY CO2 1700",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("set_speed", 1),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 3),
|
||||||
|
("set_speed", 2),
|
||||||
|
("set_speed", 5),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 11
|
||||||
|
#
|
||||||
|
# Проверяем максимальную скорость 6.
|
||||||
|
#
|
||||||
|
# CO2 = 2200
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.state.co2 = 2200
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 11 — CO2 2200",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 12
|
||||||
|
#
|
||||||
|
# Manual override.
|
||||||
|
#
|
||||||
|
# Пользователь управляет Tion вручную.
|
||||||
|
#
|
||||||
|
# AUTO должен полностью отойти в сторону.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.override_active = True
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 12 — MANUAL OVERRIDE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
== "manual_override"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Никаких новых команд.
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 13
|
||||||
|
#
|
||||||
|
# Manual override закончился.
|
||||||
|
#
|
||||||
|
# AUTO должен заново выбрать скорость
|
||||||
|
# по текущему CO2.
|
||||||
|
#
|
||||||
|
# CO2 остаётся 2200 -> speed 6.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.override_active = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 13 — OVERRIDE FINISHED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "active"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 6
|
||||||
|
)
|
||||||
|
|
||||||
|
# target_speed был сброшен во время override,
|
||||||
|
# поэтому команда должна быть отправлена заново.
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 14
|
||||||
|
#
|
||||||
|
# Qingping снова падает.
|
||||||
|
#
|
||||||
|
# fallback = 2.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 14 — SECOND FAILURE",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 15
|
||||||
|
#
|
||||||
|
# Началась другая AUTO-точка расписания.
|
||||||
|
#
|
||||||
|
# Новый fallback = 3.
|
||||||
|
#
|
||||||
|
# Qingping по-прежнему offline.
|
||||||
|
#
|
||||||
|
# Ожидаем смену fallback:
|
||||||
|
#
|
||||||
|
# 2 -> 3
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.fallback_speed = 3
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 15 — FALLBACK CHANGED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls[-1] == (
|
||||||
|
"set_speed",
|
||||||
|
3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 16
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
#
|
||||||
|
# AutoController перестаёт управлять Tion.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule.auto_active = False
|
||||||
|
schedule.fallback_speed = None
|
||||||
|
|
||||||
|
await auto._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 16 — AUTO FINISHED",
|
||||||
|
auto,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["state"]
|
||||||
|
== "inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["reason"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["target_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
# ========================================================
|
||||||
|
# TEST 17
|
||||||
|
#
|
||||||
|
# CO2 policy отсутствует.
|
||||||
|
#
|
||||||
|
# Например, auto.yaml повреждён.
|
||||||
|
# AUTO обязан использовать fallback.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
schedule2 = FakeScheduleService(
|
||||||
|
enabled=True,
|
||||||
|
auto_active=True,
|
||||||
|
fallback_speed=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
qingping2 = FakeQingpingService(
|
||||||
|
online=True,
|
||||||
|
co2=2500,
|
||||||
|
)
|
||||||
|
|
||||||
|
tion2 = FakeTionService()
|
||||||
|
|
||||||
|
auto2 = AutoController(
|
||||||
|
schedule_service=schedule2,
|
||||||
|
qingping_service=qingping2,
|
||||||
|
tion_service=tion2,
|
||||||
|
policy=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await auto2._process()
|
||||||
|
|
||||||
|
print_state(
|
||||||
|
"TEST 17 — POLICY UNAVAILABLE",
|
||||||
|
auto2,
|
||||||
|
tion2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["state"]
|
||||||
|
== "fallback"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["reason"]
|
||||||
|
== "auto_policy_unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["target_speed"]
|
||||||
|
== 4
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
auto2.status()["auto_speed"]
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion2.controller.calls == [
|
||||||
|
("set_speed", 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO CONTROLLER TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=None,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
return controller, tion
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heater_off_after_on():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None),
|
||||||
|
("heater_off", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_heater_off():
|
||||||
|
|
||||||
|
controller, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._set_heater(
|
||||||
|
False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heater_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_on()
|
||||||
|
|
||||||
|
await test_heater_off_after_on()
|
||||||
|
|
||||||
|
await test_duplicate_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER COMMAND "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp=20.0,
|
||||||
|
):
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
scheduled_settings=(
|
||||||
|
SimpleNamespace(
|
||||||
|
target_temp=target_temp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = (
|
||||||
|
FakeQingpingService()
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_qingping_heating():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# 19.4 < 19.5
|
||||||
|
# Heater должен включиться.
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.4
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 19.8
|
||||||
|
#
|
||||||
|
# Heater уже ON.
|
||||||
|
# До 20 градусов ещё не дошли.
|
||||||
|
# Продолжаем греть.
|
||||||
|
#
|
||||||
|
# Новую команду отправлять не нужно.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 19.8
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 20.0
|
||||||
|
#
|
||||||
|
# Цель достигнута.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tion_temperature_fallback():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
# Qingping отключился.
|
||||||
|
qingping.online = False
|
||||||
|
|
||||||
|
# Используем Tion.
|
||||||
|
tion.state.in_temp = 19
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_on", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "tion"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_temperature():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution()
|
||||||
|
|
||||||
|
qingping.online = False
|
||||||
|
tion.online = False
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_temperature_missing():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=None
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_qingping_heating()
|
||||||
|
|
||||||
|
await test_tion_temperature_fallback()
|
||||||
|
|
||||||
|
await test_no_temperature()
|
||||||
|
|
||||||
|
await test_target_temperature_missing()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER PROCESS "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.controller import (
|
||||||
|
AutoController,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=19,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_uses_auto_target_temperature():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = SimpleNamespace(
|
||||||
|
# Последний SET хотел 23°C.
|
||||||
|
scheduled_settings=SimpleNamespace(
|
||||||
|
target_temp=23,
|
||||||
|
),
|
||||||
|
|
||||||
|
# Но текущий AUTO явно хочет 20°C.
|
||||||
|
auto_target_temp=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# При 19.5°C и target=20°C:
|
||||||
|
#
|
||||||
|
# heater был OFF,
|
||||||
|
# нижняя граница = 19.5°C.
|
||||||
|
#
|
||||||
|
# Поэтому heater должен остаться OFF.
|
||||||
|
#
|
||||||
|
# Если бы контроллер ошибочно использовал
|
||||||
|
# SET target_temp=23, он бы включил heater.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
("heater_off", None)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
== 19.5
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
== "qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_uses_auto_target_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TARGET "
|
||||||
|
"TEMPERATURE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.config import TemperatureConfig
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
from app.auto.temperature_policy import TemperaturePolicy
|
||||||
|
from app.my_dataclasses import MAX_TARGET_TEMP
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=19.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=22,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller():
|
||||||
|
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
temperature_policy = TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
temperature_policy=temperature_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
controller,
|
||||||
|
qingping,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_resolution(
|
||||||
|
target_temp: int,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
auto_target_temp=target_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_heating_sets_tion_target_first():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qingping = 19.0
|
||||||
|
# AUTO target = 20
|
||||||
|
#
|
||||||
|
# Нужно греть.
|
||||||
|
#
|
||||||
|
# Сначала Tion.target_temp поднимается
|
||||||
|
# до технического максимума,
|
||||||
|
# затем включается heater.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
MAX_TARGET_TEMP,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"heater_on",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_temperature
|
||||||
|
== MAX_TARGET_TEMP
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repeated_heating_does_not_repeat_commands():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Температура всё ещё ниже цели.
|
||||||
|
# AUTO продолжает хотеть нагрев,
|
||||||
|
# но повторно слать команды Tion не нужно.
|
||||||
|
qingping.state.temperature = 19.2
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_target_reached_turns_heater_off():
|
||||||
|
|
||||||
|
controller, qingping, tion = (
|
||||||
|
make_controller()
|
||||||
|
)
|
||||||
|
|
||||||
|
resolution = make_resolution(
|
||||||
|
target_temp=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сначала включаем нагрев.
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# Цель достигнута.
|
||||||
|
qingping.state.temperature = 20.0
|
||||||
|
|
||||||
|
await controller._process_heater(
|
||||||
|
resolution
|
||||||
|
)
|
||||||
|
|
||||||
|
# MAX_TARGET_TEMP обратно сейчас не меняем.
|
||||||
|
# Достаточно запретить нагрев.
|
||||||
|
assert tion.controller.calls == [
|
||||||
|
(
|
||||||
|
"heater_off",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_heating_sets_tion_target_first()
|
||||||
|
|
||||||
|
await test_repeated_heating_does_not_repeat_commands()
|
||||||
|
|
||||||
|
await test_target_reached_turns_heater_off()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER TION TARGET "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeScheduleService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.paused = True
|
||||||
|
self.override_active = False
|
||||||
|
|
||||||
|
def resolve(self, now):
|
||||||
|
return SimpleNamespace(
|
||||||
|
enabled=True,
|
||||||
|
auto_active=True,
|
||||||
|
auto_fallback_speed=1,
|
||||||
|
auto_target_temp=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
co2=1200,
|
||||||
|
temperature=19.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(self, speed):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.online = True
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=19,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_schedule_suspends_auto():
|
||||||
|
|
||||||
|
schedule = FakeScheduleService()
|
||||||
|
qingping = FakeQingpingService()
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
controller = AutoController(
|
||||||
|
schedule_service=schedule,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Имитируем состояние,
|
||||||
|
# которое AUTO помнил до pause.
|
||||||
|
controller._target_speed = 3
|
||||||
|
controller._auto_speed = 3
|
||||||
|
|
||||||
|
controller._target_heater = True
|
||||||
|
controller._auto_heater = True
|
||||||
|
|
||||||
|
controller._target_temperature = 30
|
||||||
|
|
||||||
|
controller._temperature = 19.0
|
||||||
|
controller._temperature_source = (
|
||||||
|
"qingping"
|
||||||
|
)
|
||||||
|
|
||||||
|
await controller._process()
|
||||||
|
|
||||||
|
status = controller.status()
|
||||||
|
|
||||||
|
# AUTO должен быть остановлен.
|
||||||
|
assert (
|
||||||
|
status["state"]
|
||||||
|
== "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
status["reason"]
|
||||||
|
== "schedule_paused"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Никаких команд Tion.
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
# Старое владение AUTO забыто.
|
||||||
|
assert (
|
||||||
|
controller._target_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_heater
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._auto_heater
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._target_temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
controller._temperature_source
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_paused_schedule_suspends_auto()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO SCHEDULE PAUSE "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from app.auto.config import (
|
||||||
|
load_auto_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BASE_CONFIG = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "auto.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_auto_config(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_temperature_config():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("VALID TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"hysteresis:",
|
||||||
|
config.temperature.hysteresis,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_config_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("MISSING TEMPERATURE CONFIG")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature config is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Missing temperature config "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_must_be_mapping():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature: 0.5
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEMPERATURE MUST BE MAPPING")
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "temperature must be an object"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Invalid temperature mapping "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_be_number():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: abc
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST BE NUMBER"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be a number"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Non-numeric hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_hysteresis_must_not_be_negative():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: -0.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"HYSTERESIS MUST NOT BE NEGATIVE"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"temperature.hysteresis "
|
||||||
|
"must be >= 0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Negative hysteresis "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_temperature_field():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
check_interval: 5.0
|
||||||
|
|
||||||
|
co2:
|
||||||
|
base_speed: 1
|
||||||
|
hysteresis: 100
|
||||||
|
|
||||||
|
thresholds:
|
||||||
|
- ppm: 800
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
temperature:
|
||||||
|
hysteresis: 0.5
|
||||||
|
something_else: 123
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"UNKNOWN TEMPERATURE FIELD"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
print(exc)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== (
|
||||||
|
"Unknown temperature config fields: "
|
||||||
|
"['something_else']"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"Unknown temperature field "
|
||||||
|
"must raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_temperature_is_allowed_top_level_field():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
BASE_CONFIG
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert (
|
||||||
|
config.temperature.hysteresis
|
||||||
|
== 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_valid_temperature_config()
|
||||||
|
test_temperature_config_required()
|
||||||
|
test_temperature_must_be_mapping()
|
||||||
|
test_temperature_hysteresis_must_be_number()
|
||||||
|
test_temperature_hysteresis_must_not_be_negative()
|
||||||
|
test_unknown_temperature_field()
|
||||||
|
test_temperature_is_allowed_top_level_field()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE CONFIG "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from app.auto.config import (
|
||||||
|
TemperatureConfig,
|
||||||
|
)
|
||||||
|
from app.auto.temperature_policy import (
|
||||||
|
TemperaturePolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_policy() -> TemperaturePolicy:
|
||||||
|
|
||||||
|
return TemperaturePolicy(
|
||||||
|
TemperatureConfig(
|
||||||
|
hysteresis=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_below_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.4,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_at_lower_limit():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.5,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_off_inside_hysteresis():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_below_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=19.8,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_at_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.0,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_heater_on_above_target():
|
||||||
|
|
||||||
|
policy = make_policy()
|
||||||
|
|
||||||
|
result = policy.heater_required(
|
||||||
|
temperature=20.2,
|
||||||
|
target_temp=20.0,
|
||||||
|
heater_on=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_heater_off_below_lower_limit()
|
||||||
|
test_heater_off_at_lower_limit()
|
||||||
|
test_heater_off_inside_hysteresis()
|
||||||
|
|
||||||
|
test_heater_on_below_target()
|
||||||
|
test_heater_on_at_target()
|
||||||
|
test_heater_on_above_target()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE POLICY "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.auto.controller import AutoController
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQingpingService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
temperature,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
online: bool,
|
||||||
|
in_temp,
|
||||||
|
):
|
||||||
|
self.online = online
|
||||||
|
|
||||||
|
self.state = SimpleNamespace(
|
||||||
|
in_temp=in_temp,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_controller(
|
||||||
|
*,
|
||||||
|
qingping_online: bool,
|
||||||
|
qingping_temperature,
|
||||||
|
tion_online: bool,
|
||||||
|
tion_temperature,
|
||||||
|
):
|
||||||
|
|
||||||
|
qingping = FakeQingpingService(
|
||||||
|
online=qingping_online,
|
||||||
|
temperature=qingping_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
tion = FakeTionService(
|
||||||
|
online=tion_online,
|
||||||
|
in_temp=tion_temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AutoController(
|
||||||
|
schedule_service=None,
|
||||||
|
qingping_service=qingping,
|
||||||
|
tion_service=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_qingping_priority():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 19.7
|
||||||
|
assert source == "qingping"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=19.7,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tion_fallback_when_qingping_temperature_missing():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=True,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature == 18
|
||||||
|
assert source == "tion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_tion_is_not_used():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=False,
|
||||||
|
|
||||||
|
# Значение специально оставляем.
|
||||||
|
# Оно имитирует старый state Tion.
|
||||||
|
tion_temperature=18,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_tion_temperature():
|
||||||
|
|
||||||
|
controller = make_controller(
|
||||||
|
qingping_online=False,
|
||||||
|
qingping_temperature=None,
|
||||||
|
tion_online=True,
|
||||||
|
tion_temperature=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
temperature, source = (
|
||||||
|
controller._get_temperature()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert temperature is None
|
||||||
|
assert source is None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_qingping_priority()
|
||||||
|
|
||||||
|
test_tion_fallback()
|
||||||
|
|
||||||
|
test_tion_fallback_when_qingping_temperature_missing()
|
||||||
|
|
||||||
|
test_offline_tion_is_not_used()
|
||||||
|
|
||||||
|
test_missing_tion_temperature()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE SOURCE "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from bleak import BleakScanner
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("Scanning for 20 seconds...")
|
||||||
|
|
||||||
|
devices = await BleakScanner.discover(
|
||||||
|
timeout=20.0,
|
||||||
|
return_adv=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Found: {len(devices)} devices")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for address, (device, adv) in devices.items():
|
||||||
|
print("ADDRESS:", address)
|
||||||
|
print("NAME:", device.name)
|
||||||
|
print("RSSI:", adv.rssi)
|
||||||
|
print("UUIDS:", adv.service_uuids)
|
||||||
|
print("SERVICE DATA:", adv.service_data)
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
from app.auto.co2_policy import (
|
||||||
|
Co2SpeedPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
policy = Co2SpeedPolicy(
|
||||||
|
base_speed=1,
|
||||||
|
thresholds=(
|
||||||
|
(800, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(2000, 6),
|
||||||
|
),
|
||||||
|
hysteresis=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 1 — INITIAL SPEED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
cases = (
|
||||||
|
(500, 1),
|
||||||
|
(799, 1),
|
||||||
|
(800, 2),
|
||||||
|
(999, 2),
|
||||||
|
(1000, 3),
|
||||||
|
(1299, 3),
|
||||||
|
(1300, 4),
|
||||||
|
(1599, 4),
|
||||||
|
(1600, 5),
|
||||||
|
(1999, 5),
|
||||||
|
(2000, 6),
|
||||||
|
(2500, 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
for co2, expected in cases:
|
||||||
|
|
||||||
|
result = policy.select_speed(
|
||||||
|
co2,
|
||||||
|
current_speed=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"CO2={co2:<4} "
|
||||||
|
f"-> speed={result}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 2
|
||||||
|
#
|
||||||
|
# Переход 1 -> 2 при 800 ppm.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 2 — SPEED UP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = 1
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
790,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
805,
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
"790 -> speed 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"805 -> speed 2"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 3
|
||||||
|
#
|
||||||
|
# CO2 немного упал ниже 800.
|
||||||
|
#
|
||||||
|
# Без гистерезиса получили бы:
|
||||||
|
# 2 -> 1.
|
||||||
|
#
|
||||||
|
# Но пока CO2 > 700,
|
||||||
|
# остаёмся на второй скорости.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 3 — HYSTERESIS")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
790,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=790 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
750,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=750 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
700,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=700 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 4
|
||||||
|
#
|
||||||
|
# Гистерезис между speed 2 и speed 3.
|
||||||
|
#
|
||||||
|
# Вверх:
|
||||||
|
# 1000 ppm.
|
||||||
|
#
|
||||||
|
# Вниз:
|
||||||
|
# 900 ppm.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 4 — SPEED 2 / SPEED 3")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
1005,
|
||||||
|
current_speed=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 3
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=2, CO2=1005 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
950,
|
||||||
|
current_speed=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 3
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=3, CO2=950 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
900,
|
||||||
|
current_speed=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 2
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=3, CO2=900 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 5
|
||||||
|
#
|
||||||
|
# Резкий рост CO2.
|
||||||
|
#
|
||||||
|
# Не нужно ждать:
|
||||||
|
# 1 -> 2 -> 3 -> 4 -> 5
|
||||||
|
#
|
||||||
|
# Можно сразу выбрать необходимую скорость.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 5 — LARGE CO2 JUMP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
2200,
|
||||||
|
current_speed=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=1, CO2=2200 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 6
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================
|
||||||
|
# TEST 6
|
||||||
|
#
|
||||||
|
# Аналогично при сильном падении CO2
|
||||||
|
# разрешаем сразу снизить несколько ступеней.
|
||||||
|
# ========================================================
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("TEST 6 — LARGE CO2 DROP")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
speed = policy.select_speed(
|
||||||
|
650,
|
||||||
|
current_speed=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"speed=6, CO2=650 "
|
||||||
|
f"-> speed={speed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert speed == 1
|
||||||
|
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL CO2 POLICY TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
service = QingpingService(
|
||||||
|
host="192.168.7.3",
|
||||||
|
port=1883,
|
||||||
|
mac="CCB5D131BA93",
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
service.status(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await service.stop()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import json
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "192.168.7.3"
|
||||||
|
PORT = 1883
|
||||||
|
TOPIC = "qingping/CCB5D131BA93/up"
|
||||||
|
|
||||||
|
|
||||||
|
def on_connect(client, userdata, flags, reason_code, properties):
|
||||||
|
print("Connected:", reason_code)
|
||||||
|
|
||||||
|
client.subscribe(TOPIC)
|
||||||
|
|
||||||
|
|
||||||
|
def on_message(client, userdata, message):
|
||||||
|
try:
|
||||||
|
payload = json.loads(message.payload.decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
print("RAW:", message.payload)
|
||||||
|
return
|
||||||
|
|
||||||
|
message_type = str(payload.get("type"))
|
||||||
|
message_id = payload.get("id")
|
||||||
|
message_timestamp = payload.get("timestamp")
|
||||||
|
|
||||||
|
sensor_timestamps = []
|
||||||
|
|
||||||
|
sensor_data = payload.get("sensorData")
|
||||||
|
|
||||||
|
if isinstance(sensor_data, list):
|
||||||
|
for sample in sensor_data:
|
||||||
|
timestamp = sample.get("timestamp")
|
||||||
|
|
||||||
|
if isinstance(timestamp, dict):
|
||||||
|
timestamp = timestamp.get("value")
|
||||||
|
|
||||||
|
sensor_timestamps.append(timestamp)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"id={message_id!s:<4} "
|
||||||
|
f"type={message_type:<3} "
|
||||||
|
f"msg_ts={message_timestamp!s:<12} "
|
||||||
|
f"samples={sensor_timestamps}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
client = mqtt.Client(
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
client_id="qingping-raw-debug",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.on_connect = on_connect
|
||||||
|
client.on_message = on_message
|
||||||
|
|
||||||
|
client.connect(HOST, PORT, keepalive=60)
|
||||||
|
|
||||||
|
print(f"Listening: {TOPIC}")
|
||||||
|
|
||||||
|
client.loop_forever()
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
|
from app.my_dataclasses import (
|
||||||
|
QINGPING_SAMPLE_TIMEOUT,
|
||||||
|
QINGPING_RECOVERY_TIMEOUT,
|
||||||
|
)
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self):
|
||||||
|
self.now = 0.0
|
||||||
|
|
||||||
|
def monotonic(self) -> float:
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
def advance(self, seconds: float) -> None:
|
||||||
|
self.now += seconds
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMqttClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.subscriptions = []
|
||||||
|
self.published = []
|
||||||
|
|
||||||
|
def subscribe(self, topic):
|
||||||
|
self.subscriptions.append(topic)
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
topic,
|
||||||
|
payload,
|
||||||
|
):
|
||||||
|
self.published.append(
|
||||||
|
(
|
||||||
|
topic,
|
||||||
|
json.loads(payload),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
rc=mqtt.MQTT_ERR_SUCCESS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMessage:
|
||||||
|
def __init__(self, payload: dict):
|
||||||
|
self.payload = json.dumps(
|
||||||
|
payload
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_message(
|
||||||
|
timestamp: int,
|
||||||
|
co2: int,
|
||||||
|
) -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 17,
|
||||||
|
"sensorData": [
|
||||||
|
{
|
||||||
|
"timestamp": {
|
||||||
|
"value": timestamp
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"value": 25.0
|
||||||
|
},
|
||||||
|
"humidity": {
|
||||||
|
"value": 50.0
|
||||||
|
},
|
||||||
|
"co2": {
|
||||||
|
"value": co2
|
||||||
|
},
|
||||||
|
"pm25": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"pm10": {
|
||||||
|
"value": 1
|
||||||
|
},
|
||||||
|
"battery": {
|
||||||
|
"value": 100
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def service_message() -> FakeMessage:
|
||||||
|
return FakeMessage(
|
||||||
|
{
|
||||||
|
"type": 13,
|
||||||
|
"wifi_info": (
|
||||||
|
"TestWiFi,-50,1,"
|
||||||
|
"00:00:00:00:00:00"
|
||||||
|
),
|
||||||
|
"sw_version": "4.8.5",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_state(
|
||||||
|
service: QingpingService,
|
||||||
|
*,
|
||||||
|
online: bool,
|
||||||
|
recovery_waiting: bool,
|
||||||
|
co2,
|
||||||
|
):
|
||||||
|
status = service.status()
|
||||||
|
|
||||||
|
assert status["online"] is online, status
|
||||||
|
assert (
|
||||||
|
status["recovery_waiting"]
|
||||||
|
is recovery_waiting
|
||||||
|
), status
|
||||||
|
|
||||||
|
assert status["co2"] == co2, status
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_recovery_cycle() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — DOUBLE RECOVERY CYCLE")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
# _send_recovery() использует self._client.
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# START
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("1. MQTT connect")
|
||||||
|
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("2. First sensor sample")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1000,
|
||||||
|
co2=900,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Первый отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("3. First sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=900,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recovery пока НЕ отправлялся.
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Устройство снова появляется.
|
||||||
|
# Любой пакет запускает recovery.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("4. First recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Новый type 17.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("5. First recovery completed")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1015,
|
||||||
|
co2=1000,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второй отказ.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("6. Second sensor timeout")
|
||||||
|
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_SAMPLE_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Второе восстановление.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
print("7. Second recovery")
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 3
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=1030,
|
||||||
|
co2=1100,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("8. Second recovery completed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_timeout_and_retry() -> None:
|
||||||
|
print()
|
||||||
|
print("TEST — RECOVERY TIMEOUT AND RETRY")
|
||||||
|
|
||||||
|
clock = FakeClock()
|
||||||
|
|
||||||
|
service = QingpingService(
|
||||||
|
host="127.0.0.1",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = FakeMqttClient()
|
||||||
|
|
||||||
|
service._client = client
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.qingping.service.time.monotonic",
|
||||||
|
clock.monotonic,
|
||||||
|
):
|
||||||
|
|
||||||
|
# Startup recovery.
|
||||||
|
service._on_connect(
|
||||||
|
client,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 1
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type 17 так и не пришёл.
|
||||||
|
clock.advance(
|
||||||
|
QINGPING_RECOVERY_TIMEOUT + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service._watchdog_tick(
|
||||||
|
clock.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=False,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Любой следующий пакет должен разрешить
|
||||||
|
# новую recovery-попытку.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
service_message(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(client.published) == 2
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=True,
|
||||||
|
co2=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Теперь приходит type 17.
|
||||||
|
service._on_message(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
sensor_message(
|
||||||
|
timestamp=2000,
|
||||||
|
co2=1200,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_state(
|
||||||
|
service,
|
||||||
|
online=True,
|
||||||
|
recovery_waiting=False,
|
||||||
|
co2=1200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_double_recovery_cycle()
|
||||||
|
test_recovery_timeout_and_retry()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"ALL QINGPING RECOVERY TESTS PASSED"
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.my_dataclasses import QINGPING_MAC
|
||||||
|
from app.qingping.service import QingpingService
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
service = QingpingService(
|
||||||
|
QINGPING_MAC
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
print("Qingping service started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
state = service.state
|
||||||
|
|
||||||
|
print(
|
||||||
|
"online:",
|
||||||
|
service.online,
|
||||||
|
"| state:",
|
||||||
|
state.to_dict()
|
||||||
|
if state
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_FILE = "config/schedule.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(title, result):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"Enabled: {result.enabled}")
|
||||||
|
|
||||||
|
if result.current is not None:
|
||||||
|
print(
|
||||||
|
f"Current: "
|
||||||
|
f"{result.current.when} | "
|
||||||
|
f"{result.current.template} | "
|
||||||
|
f"{result.current.point.action.type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Current: None")
|
||||||
|
|
||||||
|
if result.next is not None:
|
||||||
|
print(
|
||||||
|
f"Next: "
|
||||||
|
f"{result.next.when} | "
|
||||||
|
f"{result.next.template} | "
|
||||||
|
f"{result.next.point.action.type}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Next: None")
|
||||||
|
|
||||||
|
print(f"Auto: {result.auto_active}")
|
||||||
|
print(f"Settings: {result.scheduled_settings.to_dict()}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Загрузка schedule.yaml...")
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
print("YAML успешно загружен и проверен.")
|
||||||
|
print()
|
||||||
|
|
||||||
|
service = ScheduleService(config)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 1
|
||||||
|
# Понедельник 06:30
|
||||||
|
#
|
||||||
|
# Текущая точка должна быть 00:00 power off.
|
||||||
|
# Следующая — 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 6, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 1 — Monday 06:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.when.hour == 0
|
||||||
|
assert result.next.when.hour == 7
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert result.scheduled_settings.power is False
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 2
|
||||||
|
# Понедельник 07:30
|
||||||
|
#
|
||||||
|
# Должны накопиться параметры из точки 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 7, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 2 — Monday 07:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.current.when.hour == 7
|
||||||
|
assert result.next.when.hour == 9
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 2
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
assert settings.mode == "outside"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 3
|
||||||
|
# Понедельник 10:00
|
||||||
|
#
|
||||||
|
# В 09:00 поменялась только скорость.
|
||||||
|
#
|
||||||
|
# Остальные значения должны сохраниться от 07:00.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 10, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 3 — Monday 10:00",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.current.when.hour == 9
|
||||||
|
assert result.next.when.hour == 13
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
assert settings.mode == "outside"
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 4
|
||||||
|
# Понедельник 13:10
|
||||||
|
#
|
||||||
|
# Последняя точка AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 13, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 4 — Monday 13:10 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.point.action.type == ScheduleActionType.AUTO
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
assert result.next.when.hour == 17
|
||||||
|
assert result.next.when.minute == 0
|
||||||
|
|
||||||
|
# Накопленные настройки при AUTO не стираются.
|
||||||
|
assert result.scheduled_settings.speed == 1
|
||||||
|
assert result.scheduled_settings.power is True
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 5
|
||||||
|
# Понедельник 17:10
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# Speed должен стать 3.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 17, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 5 — Monday 17:10",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
|
||||||
|
assert result.current.when.hour == 17
|
||||||
|
assert result.current.when.minute == 0
|
||||||
|
|
||||||
|
assert result.next.when.hour == 17
|
||||||
|
assert result.next.when.minute == 30
|
||||||
|
|
||||||
|
assert result.scheduled_settings.speed == 3
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 6
|
||||||
|
# Понедельник 17:40
|
||||||
|
#
|
||||||
|
# Снова AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 17, 40)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 6 — Monday 17:40 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
assert result.current.when.hour == 17
|
||||||
|
assert result.current.when.minute == 30
|
||||||
|
|
||||||
|
assert result.next.when.hour == 23
|
||||||
|
|
||||||
|
# Последний scheduled speed всё равно должен помнить 3.
|
||||||
|
assert result.scheduled_settings.speed == 3
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 7
|
||||||
|
# Понедельник 23:15
|
||||||
|
#
|
||||||
|
# Heater off, speed 1.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 14, 23, 15)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 7 — Monday 23:15",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert result.auto_active is False
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is False
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 8
|
||||||
|
# Суббота 09:30
|
||||||
|
#
|
||||||
|
# Проверяем переключение на weekend.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 19, 9, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 8 — Saturday 09:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.template == "weekend"
|
||||||
|
assert result.next.template == "weekend"
|
||||||
|
|
||||||
|
settings = result.scheduled_settings
|
||||||
|
|
||||||
|
assert settings.power is True
|
||||||
|
assert settings.speed == 1
|
||||||
|
assert settings.heater is True
|
||||||
|
assert settings.target_temp == 20
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 9
|
||||||
|
# Суббота 10:30
|
||||||
|
#
|
||||||
|
# Weekend AUTO.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 19, 10, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 9 — Saturday 10:30 / AUTO",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.template == "weekend"
|
||||||
|
assert result.auto_active is True
|
||||||
|
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# TEST 10
|
||||||
|
# Проверяем переход через полночь.
|
||||||
|
#
|
||||||
|
# Вторник 00:30.
|
||||||
|
# В 00:00 вторника уже должна действовать power off.
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
|
||||||
|
result = service.resolve(
|
||||||
|
datetime(2026, 9, 15, 0, 30)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_result(
|
||||||
|
"TEST 10 — Tuesday 00:30",
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.current.when.day == 15
|
||||||
|
assert result.current.when.hour == 0
|
||||||
|
|
||||||
|
assert result.scheduled_settings.power is False
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("ALL SCHEDULE TESTS PASSED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: off
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def power_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def power_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(self, operation):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def print_resolution(
|
||||||
|
title,
|
||||||
|
resolution,
|
||||||
|
):
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(title)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Current:",
|
||||||
|
resolution.current.when
|
||||||
|
if resolution.current
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Action:",
|
||||||
|
resolution.current.point.action.type
|
||||||
|
if resolution.current
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Scheduled speed:",
|
||||||
|
resolution.scheduled_settings.speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"AUTO:",
|
||||||
|
resolution.auto_active,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Fallback:",
|
||||||
|
resolution.auto_fallback_speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve(service: ScheduleService):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
# Обычный SET speed=1
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 1 — SET",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
# AUTO speed=3
|
||||||
|
#
|
||||||
|
# scheduled_settings.speed всё ещё 1,
|
||||||
|
# потому что AUTO не участвует
|
||||||
|
# в накоплении SET-настроек.
|
||||||
|
#
|
||||||
|
# Но fallback должен быть 3.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 2 — AUTO",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.current.point.action.type
|
||||||
|
== ScheduleActionType.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
# AUTO закончился.
|
||||||
|
# SET speed=2.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_resolution(
|
||||||
|
"TEST 3 — AFTER AUTO",
|
||||||
|
resolution,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.auto_active is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.speed
|
||||||
|
== 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_process(
|
||||||
|
service: ScheduleService,
|
||||||
|
tion: FakeTionService,
|
||||||
|
):
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
#
|
||||||
|
# Обычный SET.
|
||||||
|
# ScheduleService должен применить speed=1.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"09:30 calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
1,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
# Очищаем историю команд.
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
#
|
||||||
|
# AUTO.
|
||||||
|
#
|
||||||
|
# В расписании fallback speed=3,
|
||||||
|
# но ScheduleService НЕ должен
|
||||||
|
# отправить ни speed=1, ни speed=3.
|
||||||
|
#
|
||||||
|
# Скорость теперь принадлежит
|
||||||
|
# будущему AutoController.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"10:30 AUTO calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
speed_calls = [
|
||||||
|
call
|
||||||
|
for call in tion.controller.calls
|
||||||
|
if call[0] == "set_speed"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert speed_calls == []
|
||||||
|
|
||||||
|
# Очищаем историю.
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# Расписание снова должно поставить speed=2.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
fixed_now = datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = (
|
||||||
|
fixed_now
|
||||||
|
)
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"12:30 calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Проверка resolve()
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
test_resolve(service)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# Проверка реального _process(),
|
||||||
|
# но через FakeTion.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
await test_process(
|
||||||
|
service,
|
||||||
|
tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("ALL AUTO SCHEDULE TESTS PASSED")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: off
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
heater: on
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
heater: off
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def power_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def power_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("power_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_speed(self, speed: int):
|
||||||
|
self.calls.append(
|
||||||
|
("set_speed", speed)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оставляем оба варианта управления heater,
|
||||||
|
# чтобы fake соответствовал фактическому
|
||||||
|
# интерфейсу TionController.
|
||||||
|
async def heater_on(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_on", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heater_off(self):
|
||||||
|
self.calls.append(
|
||||||
|
("heater_off", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_heater(self, enabled: bool):
|
||||||
|
self.calls.append(
|
||||||
|
("set_heater", enabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_target_temperature(
|
||||||
|
self,
|
||||||
|
temperature: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_target_temperature",
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = FakeTionController()
|
||||||
|
|
||||||
|
async def execute(self, operation):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def heater_calls(calls):
|
||||||
|
return [
|
||||||
|
call
|
||||||
|
for call in calls
|
||||||
|
if call[0] in {
|
||||||
|
"heater_on",
|
||||||
|
"heater_off",
|
||||||
|
"set_heater",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def process_at(
|
||||||
|
service: ScheduleService,
|
||||||
|
when: datetime,
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"schedule.service.datetime",
|
||||||
|
wraps=datetime,
|
||||||
|
) as mocked_datetime:
|
||||||
|
|
||||||
|
mocked_datetime.now.return_value = when
|
||||||
|
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auto_does_not_control_heater():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 09:30
|
||||||
|
#
|
||||||
|
# Обычный SET.
|
||||||
|
# heater=on должен принадлежать ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"09:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"SET must control heater"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 10:30
|
||||||
|
#
|
||||||
|
# AUTO.
|
||||||
|
#
|
||||||
|
# В накопленных scheduled_settings heater всё ещё
|
||||||
|
# должен быть True, потому что последняя SET-точка
|
||||||
|
# включила heater.
|
||||||
|
#
|
||||||
|
# Но ScheduleService НЕ должен отправлять
|
||||||
|
# никаких heater-команд.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"10:30 scheduled heater:",
|
||||||
|
resolution.scheduled_settings.heater,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.heater
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
"10:30 AUTO calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [], (
|
||||||
|
"ScheduleService must not control "
|
||||||
|
"heater during AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
tion.controller.calls.clear()
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# 12:30
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# heater=off снова принадлежит ScheduleService.
|
||||||
|
# --------------------------------------------------
|
||||||
|
|
||||||
|
await process_at(
|
||||||
|
service,
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"12:30 SET calls:",
|
||||||
|
tion.controller.calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
calls = heater_calls(
|
||||||
|
tion.controller.calls
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls != [], (
|
||||||
|
"ScheduleService must regain heater "
|
||||||
|
"control after AUTO"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_auto_does_not_control_heater()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO HEATER OWNERSHIP TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleActionType,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
VALID_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(text: str):
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
text,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_target_temperature():
|
||||||
|
|
||||||
|
temp_dir, config = load_config(
|
||||||
|
VALID_YAML
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
point = (
|
||||||
|
config.templates["test"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
action = point.action
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.type
|
||||||
|
== ScheduleActionType.AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
action.settings.target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_rejects_heater():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
heater: on
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "heater" in str(exc)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO heater field must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_speed_is_required():
|
||||||
|
|
||||||
|
config_text = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
temp_dir, _ = load_config(
|
||||||
|
config_text
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as exc:
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
"Expected error:",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
str(exc)
|
||||||
|
== "AUTO action requires 'speed'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
raise AssertionError(
|
||||||
|
"AUTO without speed must "
|
||||||
|
"raise ValueError"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_target_temperature()
|
||||||
|
|
||||||
|
test_auto_rejects_heater()
|
||||||
|
|
||||||
|
test_auto_speed_is_required()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE PARSER "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "09:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
power: on
|
||||||
|
speed: 1
|
||||||
|
target_temp: 23
|
||||||
|
|
||||||
|
- time: "10:00"
|
||||||
|
action:
|
||||||
|
type: auto
|
||||||
|
speed: 3
|
||||||
|
target_temp: 20
|
||||||
|
|
||||||
|
- time: "12:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
target_temp: 22
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_temperature_resolution():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 09:30 — обычный SET
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
9,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 10:30 — AUTO
|
||||||
|
#
|
||||||
|
# Последний SET по-прежнему хранит 23,
|
||||||
|
# но AUTO явно требует 20.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
10,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_fallback_speed
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 23
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
== 20
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------
|
||||||
|
# 12:30 — снова SET
|
||||||
|
#
|
||||||
|
# AUTO закончился.
|
||||||
|
# ----------------------------------------------
|
||||||
|
|
||||||
|
resolution = service.resolve(
|
||||||
|
datetime(
|
||||||
|
2026,
|
||||||
|
9,
|
||||||
|
14,
|
||||||
|
12,
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_active
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.scheduled_settings.target_temp
|
||||||
|
== 22
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
resolution.auto_target_temp
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
test_auto_temperature_resolution()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL AUTO TEMPERATURE RESOLUTION "
|
||||||
|
"TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
SCHEDULE_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
tion = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion,
|
||||||
|
check_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tion.start()
|
||||||
|
await schedule.start()
|
||||||
|
|
||||||
|
print("Schedule started. Ctrl+C to stop.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if tion.state:
|
||||||
|
print(
|
||||||
|
f"{datetime.datetime.now().strftime("%y-%m-%d %H:%M:%S")} "
|
||||||
|
f"power={tion.state.power} "
|
||||||
|
f"speed={tion.state.fan_speed} "
|
||||||
|
f"heater={tion.state.heater} "
|
||||||
|
f"target_temp={tion.state.target_temp} "
|
||||||
|
f"last_error={tion.last_error} "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await schedule.stop()
|
||||||
|
await tion.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.my_dataclasses import TION_MAC
|
||||||
|
from app.tion import TionController, TionService
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
ScheduledSettings,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
SCHEDULE_FILE = (
|
||||||
|
PROJECT_ROOT
|
||||||
|
/ "config"
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
config = load_schedule(SCHEDULE_FILE)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
tion = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion,
|
||||||
|
check_interval=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tion.start()
|
||||||
|
await schedule.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print()
|
||||||
|
print("Schedule started")
|
||||||
|
|
||||||
|
resolution = schedule.resolve()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Current: "
|
||||||
|
f"{resolution.current.when if resolution.current else None}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Next: "
|
||||||
|
f"{resolution.next.when if resolution.next else None}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# print()
|
||||||
|
# print("Applying temporary override:")
|
||||||
|
# print("speed=6")
|
||||||
|
# print()
|
||||||
|
#
|
||||||
|
# await schedule.apply_override(
|
||||||
|
# ScheduledSettings(
|
||||||
|
# speed=6,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
while True:
|
||||||
|
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if ( datetime.now() > datetime(2026, 9, 18, 20, 30, 30)
|
||||||
|
and datetime.now() < datetime(2026, 9, 18, 20, 30, 36) ):
|
||||||
|
print("Applying temporary override:")
|
||||||
|
print("heater=True")
|
||||||
|
print("target_temp=25")
|
||||||
|
await schedule.apply_override(
|
||||||
|
ScheduledSettings(
|
||||||
|
heater=True,
|
||||||
|
target_temp=25,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now().strftime(
|
||||||
|
"%y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = tion.state
|
||||||
|
|
||||||
|
if state is None:
|
||||||
|
print(
|
||||||
|
f"{now} state=None"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{now} "
|
||||||
|
f"power={state.power} "
|
||||||
|
f"speed={state.fan_speed} "
|
||||||
|
f"heater={state.heater} "
|
||||||
|
f"target_temp={state.target_temp} "
|
||||||
|
f"override={schedule.override_active} "
|
||||||
|
f"until={schedule.override_until}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await schedule.stop()
|
||||||
|
await tion.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
ScheduledSettings,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionController:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def set_speed(
|
||||||
|
self,
|
||||||
|
speed: int,
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"set_speed",
|
||||||
|
speed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTionService:
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.controller = (
|
||||||
|
FakeTionController()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def execute(
|
||||||
|
self,
|
||||||
|
operation,
|
||||||
|
):
|
||||||
|
return await operation(
|
||||||
|
self.controller
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_test_schedule():
|
||||||
|
|
||||||
|
temp_dir = TemporaryDirectory()
|
||||||
|
|
||||||
|
path = (
|
||||||
|
Path(temp_dir.name)
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
config = load_schedule(path)
|
||||||
|
|
||||||
|
return temp_dir, config
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_schedule_does_not_control_tion():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tion = FakeTionService()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
tion=tion,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
await service.pause()
|
||||||
|
|
||||||
|
assert service.paused is True
|
||||||
|
|
||||||
|
# Даже если фоновый цикл вызывает _process(),
|
||||||
|
# никаких команд быть не должно.
|
||||||
|
await service._process()
|
||||||
|
|
||||||
|
assert tion.controller.calls == []
|
||||||
|
|
||||||
|
# Возвращаем расписание.
|
||||||
|
#
|
||||||
|
# resume() должен сразу применить
|
||||||
|
# текущее состояние расписания.
|
||||||
|
await service.resume()
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"set_speed",
|
||||||
|
2,
|
||||||
|
) in tion.controller.calls
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_clears_override():
|
||||||
|
|
||||||
|
temp_dir, config = (
|
||||||
|
load_test_schedule()
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ScheduleService(
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
|
# Имитируем уже существующий
|
||||||
|
# temporary override.
|
||||||
|
service._override_settings = (
|
||||||
|
ScheduledSettings(
|
||||||
|
speed=5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
service._override_until = (
|
||||||
|
datetime.now()
|
||||||
|
+ timedelta(hours=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
service._override_pending = True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_settings.speed
|
||||||
|
== 5
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.pause()
|
||||||
|
|
||||||
|
assert service.paused is True
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_settings.speed
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service.override_until
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
service._override_pending
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
temp_dir.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_paused_schedule_does_not_control_tion()
|
||||||
|
|
||||||
|
await test_pause_clears_override()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL SCHEDULE PAUSE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from schedule import (
|
||||||
|
ScheduleService,
|
||||||
|
load_schedule,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEDULE_YAML = """
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
enabled: true
|
||||||
|
timezone: local
|
||||||
|
|
||||||
|
templates:
|
||||||
|
|
||||||
|
test:
|
||||||
|
|
||||||
|
- time: "00:00"
|
||||||
|
action:
|
||||||
|
type: set
|
||||||
|
speed: 2
|
||||||
|
|
||||||
|
days:
|
||||||
|
mon: test
|
||||||
|
tue: test
|
||||||
|
wed: test
|
||||||
|
thu: test
|
||||||
|
fri: test
|
||||||
|
sat: test
|
||||||
|
sun: test
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_config(
|
||||||
|
directory: Path,
|
||||||
|
):
|
||||||
|
|
||||||
|
schedule_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule_path.write_text(
|
||||||
|
SCHEDULE_YAML,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
return load_schedule(
|
||||||
|
schedule_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pause_survives_restart():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Первый экземпляр сервиса.
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
service1 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service1.paused is False
|
||||||
|
|
||||||
|
await service1.pause()
|
||||||
|
|
||||||
|
assert service1.paused is True
|
||||||
|
assert state_path.exists()
|
||||||
|
|
||||||
|
# --------------------------------------
|
||||||
|
# Имитируем перезапуск приложения:
|
||||||
|
# создаём новый ScheduleService.
|
||||||
|
# --------------------------------------
|
||||||
|
|
||||||
|
service2 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service2.paused is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resume_survives_restart():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "schedule_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
service1 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service1.pause()
|
||||||
|
|
||||||
|
assert service1.paused is True
|
||||||
|
|
||||||
|
await service1.resume()
|
||||||
|
|
||||||
|
assert service1.paused is False
|
||||||
|
|
||||||
|
# Новый экземпляр после resume
|
||||||
|
# тоже должен быть активным.
|
||||||
|
service2 = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service2.paused is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_state_file_defaults_to_active():
|
||||||
|
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
|
||||||
|
directory = Path(temp_dir)
|
||||||
|
|
||||||
|
config = create_config(
|
||||||
|
directory
|
||||||
|
)
|
||||||
|
|
||||||
|
state_path = (
|
||||||
|
directory
|
||||||
|
/ "missing_state.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not state_path.exists()
|
||||||
|
|
||||||
|
service = ScheduleService(
|
||||||
|
config,
|
||||||
|
state_path=state_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert service.paused is False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
await test_pause_survives_restart()
|
||||||
|
await test_resume_survives_restart()
|
||||||
|
await test_missing_state_file_defaults_to_active()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print(
|
||||||
|
"ALL SCHEDULE PAUSE "
|
||||||
|
"PERSISTENCE TESTS PASSED"
|
||||||
|
)
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.tion import TionController
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def print_state(title: str, state) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 50)
|
||||||
|
print(title)
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
tion = TionController(TION_MAC)
|
||||||
|
|
||||||
|
print("Подключение к Tion...")
|
||||||
|
|
||||||
|
async with tion:
|
||||||
|
|
||||||
|
print(f"Connected: {tion.connected}")
|
||||||
|
|
||||||
|
# Читаем исходное состояние
|
||||||
|
state = await tion.get_state()
|
||||||
|
print_state("Исходное состояние", state)
|
||||||
|
|
||||||
|
# Включаем
|
||||||
|
state = await tion.power_on()
|
||||||
|
print_state("После POWER ON", state)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Скорость 2
|
||||||
|
state = await tion.set_speed(2)
|
||||||
|
print_state("После SPEED 2", state)
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Скорость 3
|
||||||
|
state = await tion.set_speed(3)
|
||||||
|
print_state("После SPEED 3", state)
|
||||||
|
|
||||||
|
state = await tion.set_target_temperature(20)
|
||||||
|
print_state("TARGET TEMP 20°C", state)
|
||||||
|
|
||||||
|
state = await tion.set_air_mode("recirculation")
|
||||||
|
print_state("RECIRCULATION", state)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
state = await tion.set_air_mode("outside")
|
||||||
|
print_state("RECIRCULATION", state)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
state = await tion.sound_off()
|
||||||
|
print_state("SOUND OFF", state)
|
||||||
|
|
||||||
|
state = await tion.sound_on()
|
||||||
|
print_state("SOUND ON", state)
|
||||||
|
|
||||||
|
state = await tion.light_off()
|
||||||
|
print_state("LIGHT OFF", state)
|
||||||
|
|
||||||
|
state = await tion.light_on()
|
||||||
|
print_state("LIGHT ON", state)
|
||||||
|
print()
|
||||||
|
print(f"Connected after exit: {tion.connected}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from app.tion import (
|
||||||
|
TionController,
|
||||||
|
TionService,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.my_dataclasses import *
|
||||||
|
|
||||||
|
|
||||||
|
def print_service(service: TionService) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print(f"Running: {service.running}")
|
||||||
|
print(f"Online: {service.online}")
|
||||||
|
print(f"Last seen: {service.last_seen}")
|
||||||
|
print(f"Last error: {service.last_error}")
|
||||||
|
|
||||||
|
if service.state is not None:
|
||||||
|
print()
|
||||||
|
print(json.dumps(
|
||||||
|
service.state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
# Убираем лишнее логирование
|
||||||
|
logging.getLogger("asyncio").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("bleak").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("tion_btle").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
controller = TionController(TION_MAC)
|
||||||
|
|
||||||
|
service = TionService(
|
||||||
|
controller,
|
||||||
|
poll_interval=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with service:
|
||||||
|
|
||||||
|
print("=== После запуска ===")
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Ждём несколько циклов polling...")
|
||||||
|
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Устанавливаем скорость 2 ===")
|
||||||
|
|
||||||
|
state = await service.execute(
|
||||||
|
lambda tion: tion.set_speed(2)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps(
|
||||||
|
state.to_dict(),
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
))
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== Устанавливаем температуру 20°C ===")
|
||||||
|
|
||||||
|
await service.execute(
|
||||||
|
lambda tion: tion.set_target_temperature(20)
|
||||||
|
)
|
||||||
|
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=== После stop ===")
|
||||||
|
print_service(service)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
VERSION_FILE = PROJECT_DIR / "version.toml"
|
||||||
|
|
||||||
|
|
||||||
|
def get_git_commit() -> str:
|
||||||
|
"""Возвращает short hash текущего HEAD."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
|
cwd=PROJECT_DIR,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Сначала читаем текущий version.toml.
|
||||||
|
# Нам нужны значения, которые разработчик поменял вручную.
|
||||||
|
with VERSION_FILE.open("rb") as file:
|
||||||
|
data = tomllib.load(file)
|
||||||
|
|
||||||
|
major = data["version"]["major"]
|
||||||
|
minor = data["version"]["minor"]
|
||||||
|
patch = data["version"]["patch"]
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
content = f"""\
|
||||||
|
[version]
|
||||||
|
major = {major}
|
||||||
|
minor = {minor}
|
||||||
|
patch = {patch}
|
||||||
|
|
||||||
|
[build]
|
||||||
|
date = "{now:%Y-%m-%d}"
|
||||||
|
time = "{now:%H:%M:%S}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
VERSION_FILE.write_text(
|
||||||
|
content,
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Version updated: "
|
||||||
|
f"{major}.{minor}.{patch} "
|
||||||
|
f"({now:%Y-%m-%d %H:%M:%S})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="fan" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="2.1" fill="currentColor" stroke="none"/>
|
||||||
|
<path d="M12 9.9c-1.3-1.6-2.1-3.1-1.3-5.1.6-1.5 2.5-2.2 3.8-1.2 1.7 1.3 1.2 3.5-.5 5.1-.6.6-1.3 1-2 1.2ZM14.1 12c1.6-1.3 3.1-2.1 5.1-1.3 1.5.6 2.2 2.5 1.2 3.8-1.3 1.7-3.5 1.2-5.1-.5-.6-.6-1-1.3-1.2-2ZM12 14.1c1.3 1.6 2.1 3.1 1.3 5.1-.6 1.5-2.5 2.2-3.8 1.2-1.7-1.3-1.2-3.5.5-5.1.6-.6 1.3-1 2-1.2ZM9.9 12c-1.6 1.3-3.1 2.1-5.1 1.3-1.5-.6-2.2-2.5-1.2-3.8 1.3-1.7 3.5-1.2 5.1.5.6.6 1 1.3 1.2 2Z" fill="currentColor" stroke="none"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="thermometer" viewBox="0 0 24 24">
|
||||||
|
<path d="M9 14.4V5a3 3 0 0 1 6 0v9.4a5 5 0 1 1-6 0Z"/>
|
||||||
|
<path d="M12 7v9"/><circle cx="12" cy="18" r="1.7" fill="currentColor" stroke="none"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="power" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2.8v8.4"/><path d="M7.2 6.1a8 8 0 1 0 9.6 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="heat" viewBox="0 0 24 24">
|
||||||
|
<path d="M7 21c-2.7-3.1 2.5-5.5 0-8.6C4.7 9.6 8.8 7.1 7.2 3M12 21c-2.7-3.1 2.5-5.5 0-8.6C9.7 9.6 13.8 7.1 12.2 3M17 21c-2.7-3.1 2.5-5.5 0-8.6-2.3-2.8 1.8-5.3.2-9.4"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="leaf" viewBox="0 0 24 24">
|
||||||
|
<path d="M20.5 3.5C12 3.3 5.5 6.4 5.2 13.1c-.2 3.5 2.5 6.1 5.8 5.7 6.3-.8 8.6-7.5 9.5-15.3Z"/>
|
||||||
|
<path d="M3 21c3.1-5 7-8.4 12.1-11"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="drop" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2.7S5.7 9.6 5.7 14.8a6.3 6.3 0 0 0 12.6 0C18.3 9.6 12 2.7 12 2.7Z"/><path d="M9 16.2a3.3 3.3 0 0 0 3 2"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="particles" viewBox="0 0 24 24">
|
||||||
|
<g fill="currentColor" stroke="none"><circle cx="7" cy="5" r="1.3"/><circle cx="13" cy="4" r="1"/><circle cx="18" cy="7" r="1.5"/><circle cx="5" cy="11" r="1"/><circle cx="11" cy="10" r="1.5"/><circle cx="17" cy="13" r="1"/><circle cx="7" cy="17" r="1.5"/><circle cx="13" cy="19" r="1"/><circle cx="19" cy="19" r="1.3"/></g>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="filter" viewBox="0 0 24 24">
|
||||||
|
<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 7v10M12 7v10M15 7v10"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="clock" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="auto" viewBox="0 0 24 24">
|
||||||
|
<path d="M20 8V3.5L18.2 5A9 9 0 1 0 21 12M4 16v4.5L5.8 19"/><path d="m8.6 16 3.4-8 3.4 8M9.8 13h4.4"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="pause" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="9"/><path d="M9.5 8v8M14.5 8v8"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="settings" viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="home" viewBox="0 0 24 24">
|
||||||
|
<path d="m3 11 9-8 9 8"/><path d="M5.5 9.5V21h13V9.5M9.5 21v-7h5v7"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="wind" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 7h9a3 3 0 1 0-2.6-4.5M3 12h15a3 3 0 1 1-2.7 4.3M3 17h7"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 266 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 388 KiB |
@@ -0,0 +1,293 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--page: #dcecf2;
|
||||||
|
--page-deep: #b7d4df;
|
||||||
|
--glass: rgba(244, 251, 253, 0.58);
|
||||||
|
--glass-strong: rgba(248, 253, 255, 0.76);
|
||||||
|
--glass-soft: rgba(255, 255, 255, 0.34);
|
||||||
|
--line: rgba(255, 255, 255, 0.72);
|
||||||
|
--line-soft: rgba(63, 104, 121, 0.14);
|
||||||
|
--text: #17313e;
|
||||||
|
--muted: #617b87;
|
||||||
|
--faint: #8ca0a9;
|
||||||
|
--blue: #198ed1;
|
||||||
|
--blue-deep: #096ca7;
|
||||||
|
--blue-soft: rgba(25, 142, 209, 0.14);
|
||||||
|
--green: #20a66a;
|
||||||
|
--green-soft: rgba(32, 166, 106, 0.14);
|
||||||
|
--orange: #e98a2c;
|
||||||
|
--orange-soft: rgba(233, 138, 44, 0.16);
|
||||||
|
--red: #d95665;
|
||||||
|
--red-soft: rgba(217, 86, 101, 0.14);
|
||||||
|
--yellow: #d7a823;
|
||||||
|
--shadow: 0 24px 70px rgba(42, 86, 105, 0.2), 0 2px 10px rgba(43, 82, 98, 0.08);
|
||||||
|
--shadow-soft: 0 10px 32px rgba(39, 80, 96, 0.12);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 21px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--blur: 26px;
|
||||||
|
font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--page: #0d161d;
|
||||||
|
--page-deep: #172b39;
|
||||||
|
--glass: rgba(24, 38, 49, 0.68);
|
||||||
|
--glass-strong: rgba(28, 44, 56, 0.86);
|
||||||
|
--glass-soft: rgba(255, 255, 255, 0.045);
|
||||||
|
--line: rgba(193, 226, 240, 0.16);
|
||||||
|
--line-soft: rgba(188, 220, 234, 0.1);
|
||||||
|
--text: #eff8fb;
|
||||||
|
--muted: #9cb4bf;
|
||||||
|
--faint: #6e8793;
|
||||||
|
--blue: #56b9f0;
|
||||||
|
--blue-deep: #178ccf;
|
||||||
|
--blue-soft: rgba(69, 168, 225, 0.14);
|
||||||
|
--green: #52d394;
|
||||||
|
--green-soft: rgba(48, 193, 119, 0.14);
|
||||||
|
--orange: #f2a24c;
|
||||||
|
--orange-soft: rgba(242, 162, 76, 0.15);
|
||||||
|
--red: #ff7280;
|
||||||
|
--red-soft: rgba(255, 99, 116, 0.14);
|
||||||
|
--yellow: #f2c84b;
|
||||||
|
--shadow: 0 28px 80px rgba(0, 0, 0, 0.36), 0 2px 12px rgba(0, 0, 0, 0.28);
|
||||||
|
--shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body { min-height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 10% 5%, rgba(64, 176, 219, 0.32), transparent 32rem),
|
||||||
|
radial-gradient(circle at 88% 16%, rgba(73, 144, 225, 0.23), transparent 29rem),
|
||||||
|
radial-gradient(circle at 58% 92%, rgba(75, 191, 171, 0.2), transparent 32rem),
|
||||||
|
linear-gradient(145deg, var(--page), var(--page-deep));
|
||||||
|
background-attachment: fixed;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input { font: inherit; }
|
||||||
|
button { color: inherit; }
|
||||||
|
|
||||||
|
.ambient {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient::before,
|
||||||
|
.ambient::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 28rem;
|
||||||
|
height: 28rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(34px);
|
||||||
|
opacity: 0.34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient::before { left: -10rem; top: -12rem; background: #62d7e3; }
|
||||||
|
.ambient::after { right: -12rem; bottom: -15rem; background: #58a0ef; }
|
||||||
|
|
||||||
|
.glass {
|
||||||
|
background: linear-gradient(145deg, var(--glass-strong), var(--glass));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(var(--blur)) saturate(135%);
|
||||||
|
-webkit-backdrop-filter: blur(var(--blur)) saturate(135%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-inner {
|
||||||
|
background: linear-gradient(145deg, var(--glass-soft), rgba(255,255,255,0.015));
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), var(--shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 750;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 0.78rem; min-width: 0; }
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.65rem;
|
||||||
|
height: 2.65rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(145deg, #45b9ed, #147cba);
|
||||||
|
box-shadow: 0 8px 20px rgba(18, 126, 184, 0.26), inset 0 1px 0 rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
.brand-mark svg { width: 1.42rem; height: 1.42rem; }
|
||||||
|
.brand-copy { min-width: 0; }
|
||||||
|
.brand-title { margin: 0; font-size: 1.05rem; line-height: 1.15; letter-spacing: -0.015em; }
|
||||||
|
.brand-subtitle { margin: 0.18rem 0 0; color: var(--muted); font-size: 0.72rem; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.43rem;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
padding: 0.3rem 0.66rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.055em;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.pill::before { content: ""; width: 0.43rem; height: 0.43rem; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px currentColor; opacity: 0.9; }
|
||||||
|
.pill.good { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 24%, transparent); }
|
||||||
|
.pill.info { color: var(--blue); background: var(--blue-soft); border-color: color-mix(in srgb, var(--blue) 22%, transparent); }
|
||||||
|
.pill.warn { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 24%, transparent); }
|
||||||
|
.pill.bad { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 24%, transparent); }
|
||||||
|
.pill.muted { color: var(--muted); background: var(--glass-soft); border-color: var(--line-soft); }
|
||||||
|
.pill.no-dot::before { display: none; }
|
||||||
|
|
||||||
|
.icon-button, .soft-button, .primary-button, .step-button, .toggle, .segment {
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, opacity 150ms ease, box-shadow 150ms ease;
|
||||||
|
}
|
||||||
|
.icon-button:hover, .soft-button:hover, .primary-button:hover, .step-button:hover, .toggle:hover, .segment:hover { transform: translateY(-1px); }
|
||||||
|
.icon-button:active, .soft-button:active, .primary-button:active, .step-button:active, .toggle:active, .segment:active { transform: translateY(0) scale(0.98); }
|
||||||
|
button:focus-visible, input:focus-visible { outline: 3px solid color-mix(in srgb, var(--blue) 32%, transparent); outline-offset: 2px; }
|
||||||
|
button:disabled { cursor: wait; opacity: 0.55; transform: none !important; }
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.45rem;
|
||||||
|
height: 2.45rem;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 0.82rem;
|
||||||
|
background: var(--glass-soft);
|
||||||
|
}
|
||||||
|
.icon-button svg { width: 1.12rem; height: 1.12rem; }
|
||||||
|
|
||||||
|
.soft-button, .primary-button {
|
||||||
|
min-height: 2.55rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border-radius: 0.88rem;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.soft-button { color: var(--text); background: var(--glass-soft); border: 1px solid var(--line-soft); }
|
||||||
|
.primary-button { color: white; background: linear-gradient(145deg, #35aae3, #157dbb); box-shadow: 0 9px 24px rgba(14, 119, 177, 0.25); }
|
||||||
|
.soft-button.danger { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 22%, transparent); }
|
||||||
|
|
||||||
|
.stepper { display: flex; align-items: center; justify-content: center; gap: 0.5rem; }
|
||||||
|
.step-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.28rem;
|
||||||
|
height: 2.28rem;
|
||||||
|
border-radius: 0.78rem;
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--blue-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--blue) 22%, transparent);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
padding: 0.48rem 0.58rem 0.48rem 0.7rem;
|
||||||
|
border: 1px solid var(--line-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--glass-soft);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.toggle::after {
|
||||||
|
content: "";
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--faint);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.3);
|
||||||
|
}
|
||||||
|
.toggle[aria-pressed="true"] { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 26%, transparent); }
|
||||||
|
.toggle[aria-pressed="true"]::after { background: var(--green); box-shadow: 0 0 14px color-mix(in srgb, var(--green) 52%, transparent); }
|
||||||
|
.toggle.orange[aria-pressed="true"] { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 28%, transparent); }
|
||||||
|
.toggle.orange[aria-pressed="true"]::after { background: var(--orange); box-shadow: 0 0 14px color-mix(in srgb, var(--orange) 48%, transparent); }
|
||||||
|
|
||||||
|
.value { font-variant-numeric: tabular-nums; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.range {
|
||||||
|
--progress: 50%;
|
||||||
|
width: 100%;
|
||||||
|
height: 1.35rem;
|
||||||
|
margin: 0;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.range::-webkit-slider-runnable-track {
|
||||||
|
height: 0.42rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: linear-gradient(90deg, var(--blue) var(--progress), var(--line-soft) var(--progress));
|
||||||
|
}
|
||||||
|
.range::-moz-range-track { height: 0.42rem; border-radius: 99px; background: var(--line-soft); }
|
||||||
|
.range::-moz-range-progress { height: 0.42rem; border-radius: 99px; background: var(--blue); }
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
margin-top: -0.465rem;
|
||||||
|
appearance: none;
|
||||||
|
border: 0.26rem solid var(--glass-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--blue);
|
||||||
|
box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36);
|
||||||
|
}
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
width: 0.92rem;
|
||||||
|
height: 0.92rem;
|
||||||
|
border: 0.25rem solid var(--glass-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--blue);
|
||||||
|
box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-region { position: fixed; right: 1rem; bottom: 1rem; z-index: 50; display: grid; gap: 0.55rem; }
|
||||||
|
.toast {
|
||||||
|
max-width: min(23rem, calc(100vw - 2rem));
|
||||||
|
padding: 0.78rem 1rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
background: var(--glass-strong);
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
animation: toast-in 180ms ease both;
|
||||||
|
}
|
||||||
|
.toast.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 28%, transparent); }
|
||||||
|
@keyframes toast-in { from { transform: translateY(0.55rem); opacity: 0; } }
|
||||||
|
|
||||||
|
.skeleton { color: transparent !important; border-radius: 0.4rem; background: linear-gradient(100deg, var(--line-soft) 35%, var(--glass-soft) 50%, var(--line-soft) 65%); background-size: 200% 100%; animation: shimmer 1.3s infinite; }
|
||||||
|
@keyframes shimmer { to { background-position-x: -200%; } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,872 @@
|
|||||||
|
:root {
|
||||||
|
--panel-ink: #08265d;
|
||||||
|
--panel-muted: #315d91;
|
||||||
|
--tile: rgba(219, 241, 253, 0.22);
|
||||||
|
--tile-strong: rgba(244, 251, 255, 0.4);
|
||||||
|
--tile-line: rgba(255, 255, 255, 0.9);
|
||||||
|
--tile-line-soft: rgba(193, 226, 244, 0.5);
|
||||||
|
--tile-shadow: 0 14px 32px rgba(23, 75, 111, 0.16), inset 0 1px 0 rgba(255,255,255,0.92), inset 1px 0 0 rgba(255,255,255,0.36);
|
||||||
|
--panel-glass: rgba(211, 235, 248, 0.35);
|
||||||
|
--panel-edge: rgba(255, 255, 255, 0.92);
|
||||||
|
--blue-halo: rgba(25, 139, 255, 0.34);
|
||||||
|
--green-halo: rgba(30, 205, 120, 0.34);
|
||||||
|
--orange-halo: rgba(255, 126, 46, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--panel-ink: #edf8ff;
|
||||||
|
--panel-muted: #9fc5df;
|
||||||
|
--tile: rgba(28, 48, 65, 0.46);
|
||||||
|
--tile-strong: rgba(45, 71, 89, 0.54);
|
||||||
|
--tile-line: rgba(194, 229, 247, 0.3);
|
||||||
|
--tile-line-soft: rgba(114, 176, 207, 0.22);
|
||||||
|
--tile-shadow: 0 16px 38px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(235,249,255,0.14), inset 1px 0 0 rgba(190,228,247,0.08);
|
||||||
|
--panel-glass: rgba(13, 29, 41, 0.58);
|
||||||
|
--panel-edge: rgba(187, 225, 244, 0.32);
|
||||||
|
--blue-halo: rgba(30, 161, 255, 0.42);
|
||||||
|
--green-halo: rgba(25, 225, 129, 0.35);
|
||||||
|
--orange-halo: rgba(255, 126, 39, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { min-width: 320px; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: clamp(0.7rem, 2vw, 2rem);
|
||||||
|
color: var(--panel-ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 8% 10%, rgba(244,253,255,.95) 0 7%, transparent 24%),
|
||||||
|
radial-gradient(ellipse at 73% -8%, rgba(255,249,239,.85) 0 10%, transparent 29%),
|
||||||
|
radial-gradient(ellipse at 93% 32%, rgba(90,152,125,.52) 0 10%, transparent 28%),
|
||||||
|
radial-gradient(ellipse at 12% 80%, rgba(65,132,170,.46) 0 12%, transparent 31%),
|
||||||
|
linear-gradient(115deg, rgba(184,222,239,.94), rgba(213,236,247,.82) 37%, rgba(239,232,217,.82) 66%, rgba(129,181,183,.88));
|
||||||
|
background-attachment: fixed;
|
||||||
|
overflow-x: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 6% 2%, rgba(54,112,145,.44) 0 8%, transparent 28%),
|
||||||
|
radial-gradient(ellipse at 78% -5%, rgba(138,103,60,.31) 0 9%, transparent 28%),
|
||||||
|
radial-gradient(ellipse at 94% 36%, rgba(28,91,78,.44) 0 12%, transparent 31%),
|
||||||
|
radial-gradient(ellipse at 15% 87%, rgba(17,84,122,.42) 0 13%, transparent 33%),
|
||||||
|
linear-gradient(118deg, #081723, #122d3d 40%, #292c30 68%, #0c262c);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: -7rem;
|
||||||
|
z-index: -3;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, transparent 0 13%, rgba(255,255,255,.55) 13% 16%, transparent 16% 34%, rgba(255,255,255,.35) 34% 37%, transparent 37% 68%, rgba(255,255,255,.42) 68% 71%, transparent 71%),
|
||||||
|
linear-gradient(0deg, transparent 0 58%, rgba(228,244,251,.48) 58% 61%, transparent 61%),
|
||||||
|
radial-gradient(ellipse at 50% 114%, rgba(36,111,68,.48) 0 17%, transparent 47%);
|
||||||
|
filter: blur(26px);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 4% 22%, rgba(255,255,255,.88) 0 .8%, transparent 5%),
|
||||||
|
radial-gradient(circle at 19% 74%, rgba(246,253,255,.68) 0 1.2%, transparent 7%),
|
||||||
|
radial-gradient(circle at 62% 34%, rgba(255,241,210,.63) 0 1.3%, transparent 8%),
|
||||||
|
radial-gradient(circle at 89% 13%, rgba(244,253,255,.66) 0 1%, transparent 6%),
|
||||||
|
radial-gradient(circle at 82% 82%, rgba(209,238,228,.48) 0 1.5%, transparent 8%);
|
||||||
|
filter: blur(8px);
|
||||||
|
opacity: .86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ambient::before { width: 42rem; height: 42rem; left: -14rem; top: -18rem; background: #c9f2ff; opacity: .58; filter: blur(54px); }
|
||||||
|
.ambient::after { width: 46rem; height: 46rem; right: -14rem; bottom: -21rem; background: #ebd5a7; opacity: .38; filter: blur(64px); }
|
||||||
|
:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .24; }
|
||||||
|
:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .2; }
|
||||||
|
|
||||||
|
.climate-panel {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
width: min(100%, 104rem);
|
||||||
|
min-height: calc(100vh - clamp(1.4rem, 4vw, 4rem));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(0.65rem, 1.25vw, 1.25rem);
|
||||||
|
border: 1px solid var(--panel-edge);
|
||||||
|
outline: 1px solid rgba(255,255,255,.5);
|
||||||
|
outline-offset: -5px;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(125deg, rgba(255,255,255,.34), transparent 29%, rgba(255,255,255,.11) 67%, rgba(214,235,247,.2)),
|
||||||
|
var(--panel-glass);
|
||||||
|
box-shadow: 0 32px 90px rgba(20,60,89,.3), 0 2px 10px rgba(255,255,255,.35), inset 0 1px 0 rgba(255,255,255,.95), inset 0 -1px 0 rgba(114,162,192,.23);
|
||||||
|
backdrop-filter: blur(34px) saturate(148%);
|
||||||
|
-webkit-backdrop-filter: blur(34px) saturate(148%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .climate-panel { outline-color: rgba(203,235,250,.12); box-shadow: 0 38px 100px rgba(0,0,0,.48), 0 0 42px rgba(21,120,171,.1), inset 0 1px 0 rgba(224,246,255,.2), inset 0 -1px 0 rgba(0,0,0,.35); }
|
||||||
|
|
||||||
|
.climate-panel::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background:
|
||||||
|
linear-gradient(112deg, rgba(255,255,255,.44) 0, rgba(255,255,255,.08) 17%, transparent 38%),
|
||||||
|
radial-gradient(ellipse at 52% -16%, rgba(255,255,255,.54), transparent 48%),
|
||||||
|
radial-gradient(ellipse at 100% 100%, rgba(102,178,219,.13), transparent 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.climate-panel::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: .2;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.climate-panel > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.glass-tile, .glass-section {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--tile-line);
|
||||||
|
outline: 1px solid var(--tile-line-soft);
|
||||||
|
outline-offset: -3px;
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(255,255,255,.38), rgba(255,255,255,.06) 35%, transparent 63%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
box-shadow: var(--tile-shadow);
|
||||||
|
backdrop-filter: blur(23px) saturate(145%);
|
||||||
|
-webkit-backdrop-filter: blur(23px) saturate(145%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-tile::before, .glass-section::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08));
|
||||||
|
}
|
||||||
|
.glass-tile > *, .glass-section > * { position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
width: 13rem;
|
||||||
|
height: 13rem;
|
||||||
|
right: -5rem;
|
||||||
|
bottom: -7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(17px);
|
||||||
|
}
|
||||||
|
.speed-card::after { background: var(--blue-halo); }
|
||||||
|
.temperature-card::after { width: 18rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); }
|
||||||
|
.power-card::after { background: var(--green-halo); opacity: .64; }
|
||||||
|
.heater-card::after { background: var(--orange-halo); opacity: .58; }
|
||||||
|
|
||||||
|
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 0.65rem 0.7rem; }
|
||||||
|
.identity, .header-tools { display: flex; align-items: center; gap: 0.65rem; }
|
||||||
|
.identity h1 { margin: 0 0.35rem 0 0; font-size: clamp(1.8rem, 3.1vw, 3rem); line-height: 1; letter-spacing: -0.045em; }
|
||||||
|
.climate-panel .pill { min-height: 2.05rem; padding: .38rem .82rem; border-color: rgba(255,255,255,.62); box-shadow: inset 0 1px 0 rgba(255,255,255,.72), 0 6px 18px rgba(26,75,108,.1); backdrop-filter: blur(14px); }
|
||||||
|
.climate-panel .pill.good { box-shadow: 0 0 21px rgba(28,204,117,.18), inset 0 1px 0 rgba(255,255,255,.74); }
|
||||||
|
.climate-panel .pill.warn { box-shadow: 0 0 21px rgba(242,145,56,.17), inset 0 1px 0 rgba(255,255,255,.74); }
|
||||||
|
.climate-panel .pill.info { box-shadow: 0 0 21px rgba(31,145,236,.16), inset 0 1px 0 rgba(255,255,255,.74); }
|
||||||
|
.climate-panel .icon-button { border-color: var(--tile-line); background: linear-gradient(145deg, var(--tile-strong), var(--tile)); box-shadow: var(--tile-shadow); backdrop-filter: blur(16px); }
|
||||||
|
.header-tools { justify-content: flex-end; }
|
||||||
|
.ventilation-badge { display: flex; align-items: center; gap: 0.7rem; min-width: 22rem; padding: 0.55rem 1rem; border-radius: 1rem; }
|
||||||
|
.ventilation-badge svg { width: 2.5rem; color: #1977d2; filter: drop-shadow(0 0 9px rgba(26,128,226,.34)); }
|
||||||
|
.ventilation-badge strong, .ventilation-badge small { display: block; }
|
||||||
|
.ventilation-badge strong { font-size: 0.9rem; }
|
||||||
|
.ventilation-badge small { margin-top: 0.1rem; color: var(--panel-muted); font-size: 0.68rem; }
|
||||||
|
|
||||||
|
.primary-controls { display: grid; grid-template-columns: 2.05fr 2.05fr .95fr .95fr; gap: 0.75rem; }
|
||||||
|
.control-card, .switch-card { min-height: 17.6rem; border-radius: 1.15rem; padding: 1rem; }
|
||||||
|
.control-card { display: flex; flex-direction: column; }
|
||||||
|
.card-title { display: flex; align-items: flex-start; gap: 0.72rem; }
|
||||||
|
.card-title.compact { align-items: center; }
|
||||||
|
.card-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); letter-spacing: -0.02em; }
|
||||||
|
.card-title p { margin: 0.18rem 0 0; color: var(--panel-muted); font-size: 0.72rem; }
|
||||||
|
.feature-icon { display: grid; place-items: center; flex: 0 0 auto; width: 2.1rem; height: 2.1rem; color: #1389f4; font-size: 1.75rem; font-weight: 800; line-height: 1; text-shadow: 0 0 16px rgba(20,139,244,.46); }
|
||||||
|
.feature-icon.green { color: #08a95a; text-shadow: 0 0 20px rgba(20,210,112,.62); }
|
||||||
|
.feature-icon.orange { color: #f06419; text-shadow: 0 0 18px rgba(255,105,29,.46); }
|
||||||
|
.fan-icon { font-size: 2.2rem; }
|
||||||
|
.hero-control { display: grid; grid-template-columns: 4.3rem 1fr 4.3rem; align-items: center; gap: 1rem; margin: auto 0 0.75rem; }
|
||||||
|
.hero-value { text-align: center; font-size: clamp(3.2rem, 5vw, 4.35rem); line-height: 1; letter-spacing: -0.06em; font-variant-numeric: tabular-nums; }
|
||||||
|
.hero-step {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 4.3rem;
|
||||||
|
height: 4.3rem;
|
||||||
|
border: 1.5px solid rgba(255,255,255,.85);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: #176dbb;
|
||||||
|
background: linear-gradient(145deg, rgba(245,252,255,.42), rgba(191,224,244,.2));
|
||||||
|
box-shadow: 0 10px 24px rgba(35,92,132,.15), inset 0 1px 0 rgba(255,255,255,.9), 0 0 0 1px rgba(185,226,248,.24);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 2.3rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: transform .15s ease, filter .15s ease;
|
||||||
|
}
|
||||||
|
.hero-step.primary { color: white; background: linear-gradient(145deg, #57b5ff, #1376e4 76%); box-shadow: 0 12px 28px rgba(16,103,220,.35), 0 0 26px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.8), inset 0 -2px 5px rgba(0,67,156,.24); }
|
||||||
|
.hero-step:hover { transform: translateY(-2px); filter: brightness(1.08); box-shadow: 0 14px 32px rgba(25,112,219,.35), 0 0 32px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.78); }
|
||||||
|
.hero-step:active { transform: scale(.98); }
|
||||||
|
.speed-range { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.16rem; }
|
||||||
|
.speed-segments button { height: 1.12rem; padding: 0; border: 1px solid rgba(255,255,255,.88); border-radius: .55rem; background: linear-gradient(180deg, rgba(173,207,229,.32), rgba(98,151,190,.2)); box-shadow: inset 0 1px 2px rgba(27,70,105,.08), 0 2px 5px rgba(32,86,126,.08); cursor: pointer; transition: background .12s ease, box-shadow .12s ease, transform .12s ease; }
|
||||||
|
.speed-segments button:hover { transform: translateY(-1px); }
|
||||||
|
.speed-segments button.active { background: linear-gradient(90deg, #0e82ed, #37b8ff); box-shadow: 0 0 17px rgba(25,143,247,.56), inset 0 1px 0 rgba(255,255,255,.74), inset 0 -2px 4px rgba(0,84,182,.22); }
|
||||||
|
.speed-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: 0.45rem; padding: 0 .55rem; color: var(--panel-muted); font-size: 0.68rem; }
|
||||||
|
.temperature-range { height: 1.45rem; }
|
||||||
|
.temperature-range::-webkit-slider-runnable-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); border: 1px solid rgba(255,255,255,.92); box-shadow: 0 0 16px rgba(37,153,239,.24), 0 0 14px rgba(255,114,41,.14), inset 0 1px 2px rgba(255,255,255,.5); }
|
||||||
|
.temperature-range::-webkit-slider-thumb { width: 1.9rem; height: 1.9rem; margin-top: -.58rem; border-width: .28rem; box-shadow: 0 0 0 2px rgba(255,255,255,.55), 0 0 22px rgba(29,139,241,.58), 0 5px 13px rgba(17,79,143,.3); }
|
||||||
|
.temperature-range::-moz-range-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); }
|
||||||
|
.temperature-range::-moz-range-progress { background: transparent; }
|
||||||
|
.switch-card { display: flex; flex-direction: column; justify-content: space-between; }
|
||||||
|
.glass-switch {
|
||||||
|
width: 100%; min-height: 6rem; border: 1.5px solid rgba(255,255,255,.9); border-radius: 1rem; color: var(--panel-ink); background: rgba(199,222,235,.28); box-shadow: var(--tile-shadow); cursor: pointer; font-size: 1.75rem; font-weight: 850;
|
||||||
|
text-shadow: 0 1px 0 rgba(255,255,255,.35);
|
||||||
|
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease;
|
||||||
|
}
|
||||||
|
.glass-switch:hover { transform: translateY(-2px); filter: brightness(1.06); }
|
||||||
|
.glass-switch[aria-pressed="true"].green { background: linear-gradient(145deg, rgba(145,255,198,.9), rgba(38,194,113,.62)); border-color: rgba(213,255,232,.96); box-shadow: 0 14px 32px rgba(11,153,81,.3), 0 0 32px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 10px rgba(0,125,62,.18); }
|
||||||
|
.glass-switch[aria-pressed="true"].orange { background: linear-gradient(145deg, rgba(255,202,130,.88), rgba(245,112,40,.58)); border-color: rgba(255,236,213,.95); box-shadow: 0 14px 32px rgba(210,91,25,.3), 0 0 30px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -4px 10px rgba(168,54,0,.17); }
|
||||||
|
|
||||||
|
.content-section { margin-top: 0.75rem; padding: 0.7rem; border-radius: 1.15rem; }
|
||||||
|
.section-title { display: flex; align-items: center; gap: 0.65rem; min-height: 2.35rem; padding: 0 0.35rem 0.5rem; }
|
||||||
|
.section-title > div { display: flex; align-items: center; gap: 0.55rem; }
|
||||||
|
.section-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); }
|
||||||
|
.section-icon { display: grid; place-items: center; width: 1.8rem; height: 1.8rem; color: #207bcc; font-size: 1.55rem; text-shadow: 0 0 14px rgba(26,124,210,.38); }
|
||||||
|
.section-icon.leaf { color: #069c51; transform: rotate(-18deg); text-shadow: 0 0 15px rgba(8,173,88,.42); }
|
||||||
|
.section-title .updated { margin-left: auto; color: var(--panel-muted); font-size: 0.7rem; }
|
||||||
|
.air-grid { display: grid; grid-template-columns: 1.55fr 1fr .9fr 1fr 1fr 1.28fr; gap: 0.55rem; }
|
||||||
|
.metric-card { min-width: 0; min-height: 9.4rem; padding: 0.85rem; border-radius: 1rem; }
|
||||||
|
.metric-head, .metric-heading { display: flex; align-items: center; gap: 0.55rem; color: var(--panel-muted); font-size: 0.76rem; }
|
||||||
|
.metric-head { justify-content: space-between; }
|
||||||
|
.metric-icon { color: #137edc; font-size: 1.7rem; line-height: 1; text-shadow: 0 0 14px rgba(20,124,221,.34); }
|
||||||
|
.metric-icon.dots { letter-spacing: -.2rem; }
|
||||||
|
.metric-card > strong { display: block; margin-top: 0.75rem; font-size: clamp(1.55rem, 2.4vw, 2.2rem); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.metric-card strong small { color: var(--panel-muted); font-size: .63rem; font-weight: 600; }
|
||||||
|
.quality-chip { display: inline-flex; justify-content: center; padding: .35rem .72rem; border: 1px solid rgba(109,237,167,.82); border-radius: 999px; color: #076d3d; background: linear-gradient(145deg, rgba(179,255,214,.7), rgba(92,218,151,.48)); box-shadow: 0 0 17px rgba(33,203,116,.24), inset 0 1px 0 rgba(255,255,255,.88); font-size: .66rem; font-weight: 750; }
|
||||||
|
:root[data-theme="dark"] .quality-chip { color: #b9ffda; }
|
||||||
|
.quality-chip.small { margin-top: .8rem; min-width: 5.5rem; }
|
||||||
|
.co2-card > p { height: 1rem; margin: .2rem 0 .55rem; color: var(--panel-muted); font-size: .58rem; overflow: hidden; }
|
||||||
|
.meter { height: .82rem; border: 1px solid rgba(255,255,255,.8); border-radius: 999px; background: rgba(91,145,181,.18); overflow: hidden; }
|
||||||
|
.meter i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, #08a350, #36dc83); box-shadow: 0 0 18px rgba(18,199,104,.58), inset 0 1px 0 rgba(255,255,255,.64); transition: width .35s ease; }
|
||||||
|
.meter-labels { display: flex; justify-content: space-between; margin-top: .27rem; color: rgba(22,69,108,.78); font-size: .6rem; font-weight: 650; text-shadow: 0 1px 0 rgba(255,255,255,.48); }
|
||||||
|
:root[data-theme="dark"] .meter-labels { color: rgba(211,235,248,.76); text-shadow: 0 1px 2px rgba(0,0,0,.58); }
|
||||||
|
.filter-line { display: flex; align-items: center; gap: .45rem; margin-top: 1rem; }
|
||||||
|
.filter-meter { flex: 1; }
|
||||||
|
.filter-meter i { background: linear-gradient(90deg, #176dec, #36c1ff); box-shadow: 0 0 18px rgba(31,144,247,.55), inset 0 1px 0 rgba(255,255,255,.58); }
|
||||||
|
.filter-line > span { font-size: .65rem; font-weight: 750; }
|
||||||
|
|
||||||
|
.bottom-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 0.75rem; }
|
||||||
|
.schedule-layout { display: grid; grid-template-columns: 1.35fr 1fr; gap: 0.7rem; }
|
||||||
|
.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; gap: 1rem; align-items: center; min-height: 8.2rem; padding: 1rem 1.3rem; border-radius: 1rem; }
|
||||||
|
.schedule-times > i { width: 1px; height: 80%; background: rgba(49,93,145,.25); }
|
||||||
|
.schedule-times div { display: grid; gap: .22rem; }
|
||||||
|
.schedule-times span, .schedule-times small { color: var(--panel-muted); font-size: .68rem; }
|
||||||
|
.schedule-times strong { font-size: clamp(1.65rem, 2.8vw, 2.3rem); font-variant-numeric: tabular-nums; }
|
||||||
|
.schedule-buttons { display: grid; gap: .55rem; }
|
||||||
|
.schedule-button { display: grid; grid-template-columns: 2.4rem 1fr; align-items: center; gap: .65rem; min-height: 3.8rem; padding: .55rem .8rem; border: 1.5px solid; border-radius: .9rem; text-align: left; cursor: pointer; }
|
||||||
|
.schedule-button strong, .schedule-button small { display: block; }
|
||||||
|
.schedule-button strong { font-size: .84rem; }
|
||||||
|
.schedule-button small { margin-top: .12rem; font-size: .62rem; }
|
||||||
|
.schedule-button.auto { color: #096bda; border-color: rgba(83,176,255,.9); background: linear-gradient(145deg, rgba(224,244,255,.67), rgba(133,201,250,.26)); box-shadow: 0 9px 24px rgba(23,113,212,.19), 0 0 20px rgba(42,153,243,.13), inset 0 1px 0 rgba(255,255,255,.92); }
|
||||||
|
.schedule-button.pause { color: #a73512; border-color: rgba(250,174,102,.9); background: linear-gradient(145deg, rgba(255,240,217,.72), rgba(246,177,107,.28)); box-shadow: 0 9px 24px rgba(202,104,44,.17), 0 0 20px rgba(244,131,50,.12), inset 0 1px 0 rgba(255,255,255,.92); }
|
||||||
|
.schedule-button:hover { filter: brightness(1.06); transform: translateY(-1px); }
|
||||||
|
.action-symbol { font-size: 2rem; font-weight: 800; text-align: center; }
|
||||||
|
.mode-banner { display: flex; gap: .4rem; align-items: center; margin-top: .5rem; padding: .45rem .7rem; border-radius: .65rem; color: var(--panel-muted); background: rgba(255,255,255,.14); font-size: .62rem; }
|
||||||
|
.mode-banner small::before { content: "· "; }
|
||||||
|
.extras-grid { display: grid; grid-template-columns: .9fr 1.3fr; gap: .55rem; }
|
||||||
|
.extra-card { display: flex; align-items: center; gap: .8rem; min-height: 8.2rem; padding: 1rem; border-radius: 1rem; }
|
||||||
|
.extra-icon { color: #267fd3; font-size: 3.4rem; line-height: 1; text-shadow: 0 0 19px rgba(33,126,211,.32); }
|
||||||
|
.extra-icon.wind { font-size: 3.8rem; transform: rotate(90deg); }
|
||||||
|
.extra-card small, .extra-card strong, .extra-card em { display: block; }
|
||||||
|
.extra-card small { color: var(--panel-muted); font-size: .7rem; }
|
||||||
|
.extra-card strong { margin-top: .18rem; font-size: clamp(1.3rem, 2.2vw, 2rem); font-style: normal; }
|
||||||
|
.extra-card em { margin-top: .18rem; color: var(--panel-muted); font-size: .62rem; font-style: normal; }
|
||||||
|
.mode-card { display: grid; grid-template-columns: auto 1fr; }
|
||||||
|
.mode-card strong { font-size: .85rem; }
|
||||||
|
.segments { grid-column: 1 / -1; display: flex; gap: .3rem; }
|
||||||
|
.segment { flex: 1; padding: .42rem; border: 1px solid rgba(255,255,255,.55); border-radius: .55rem; color: var(--panel-muted); background: rgba(255,255,255,.14); cursor: pointer; font-size: .58rem; }
|
||||||
|
.segment.active { color: white; background: linear-gradient(145deg, #45a7f4, #1976d2); }
|
||||||
|
.micro-settings { display: flex; align-items: center; gap: .65rem; margin-top: .55rem; padding: 0 .25rem; color: var(--panel-muted); font-size: .58rem; }
|
||||||
|
.micro-settings > span:nth-child(2) { margin-right: auto; }
|
||||||
|
.micro-toggle { padding: .32rem .55rem; border: 1px solid var(--tile-line); border-radius: 999px; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: .56rem; }
|
||||||
|
.micro-toggle[aria-pressed="true"] { color: #087848; background: rgba(105,222,160,.38); }
|
||||||
|
.demo-note { display: block; margin-top: .55rem; color: var(--panel-muted); text-align: center; font-size: .62rem; }
|
||||||
|
|
||||||
|
/* Dark aeroglass keeps the same optical depth, with colder edges and
|
||||||
|
brighter local halos so active controls do not disappear into the glass. */
|
||||||
|
:root[data-theme="dark"] body::after { opacity: .42; mix-blend-mode: screen; }
|
||||||
|
:root[data-theme="dark"] .climate-panel::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(112deg, rgba(205,239,255,.14), rgba(255,255,255,.025) 21%, transparent 42%),
|
||||||
|
radial-gradient(ellipse at 52% -16%, rgba(112,202,244,.13), transparent 50%),
|
||||||
|
radial-gradient(ellipse at 100% 100%, rgba(23,139,186,.12), transparent 44%);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .glass-tile,
|
||||||
|
:root[data-theme="dark"] .glass-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(211,240,253,.105), rgba(255,255,255,.018) 38%, transparent 65%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .glass-tile::before,
|
||||||
|
:root[data-theme="dark"] .glass-section::before {
|
||||||
|
background: linear-gradient(115deg, rgba(218,243,255,.12), transparent 26%, transparent 74%, rgba(80,181,222,.045));
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hero-step {
|
||||||
|
color: #8ed3ff;
|
||||||
|
border-color: rgba(184,226,247,.28);
|
||||||
|
background: linear-gradient(145deg, rgba(89,137,166,.2), rgba(20,48,67,.23));
|
||||||
|
box-shadow: 0 12px 28px rgba(0,0,0,.28), inset 0 1px 0 rgba(224,246,255,.14), 0 0 0 1px rgba(60,157,205,.08);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hero-step.primary {
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(145deg, #42b8ff, #096bd3 78%);
|
||||||
|
box-shadow: 0 13px 32px rgba(0,65,139,.5), 0 0 30px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.52), inset 0 -3px 7px rgba(0,27,91,.38);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); }
|
||||||
|
:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.55); background: linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 20px rgba(24,156,255,.66), inset 0 1px 0 rgba(223,248,255,.42); }
|
||||||
|
:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { color: #eafff4; border-color: rgba(118,255,185,.54); background: linear-gradient(145deg, rgba(56,221,137,.76), rgba(8,120,69,.67)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 38px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.48); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #fff5e9; border-color: rgba(255,185,118,.53); background: linear-gradient(145deg, rgba(244,145,61,.76), rgba(143,58,14,.68)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 36px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.48); }
|
||||||
|
:root[data-theme="dark"] .quality-chip { color: #caffdf; border-color: rgba(89,232,158,.42); background: linear-gradient(145deg, rgba(45,186,113,.35), rgba(11,101,60,.3)); box-shadow: 0 0 20px rgba(20,218,118,.18), inset 0 1px 0 rgba(210,255,232,.16); }
|
||||||
|
:root[data-theme="dark"] .schedule-button.auto { color: #a8ddff; border-color: rgba(76,176,241,.48); background: linear-gradient(145deg, rgba(38,125,187,.29), rgba(15,61,91,.28)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(27,150,237,.13), inset 0 1px 0 rgba(211,241,255,.13); }
|
||||||
|
:root[data-theme="dark"] .schedule-button.pause { color: #ffc18c; border-color: rgba(242,147,70,.48); background: linear-gradient(145deg, rgba(167,82,27,.3), rgba(73,40,25,.27)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(244,117,35,.12), inset 0 1px 0 rgba(255,230,207,.12); }
|
||||||
|
:root[data-theme="dark"] .meter { border-color: rgba(200,232,247,.24); background: rgba(3,22,34,.34); box-shadow: inset 0 2px 5px rgba(0,0,0,.2); }
|
||||||
|
:root[data-theme="dark"] .micro-toggle { border-color: rgba(190,226,244,.2); background: rgba(76,117,141,.15); }
|
||||||
|
:root[data-theme="dark"] .micro-toggle[aria-pressed="true"] { color: #b8ffda; border-color: rgba(67,213,139,.35); background: rgba(21,149,85,.26); box-shadow: 0 0 15px rgba(20,208,114,.12); }
|
||||||
|
|
||||||
|
/* Photographic aeroglass refinement. The backdrop already contains optical
|
||||||
|
blur, so the UI only adds a restrained haze instead of whitening the scene. */
|
||||||
|
:root {
|
||||||
|
--tile: rgba(214, 238, 251, 0.14);
|
||||||
|
--tile-strong: rgba(247, 252, 255, 0.27);
|
||||||
|
--tile-line: rgba(255, 255, 255, 0.82);
|
||||||
|
--tile-line-soft: rgba(206, 235, 249, 0.43);
|
||||||
|
--panel-glass: rgba(200, 228, 244, 0.2);
|
||||||
|
--blue-halo: rgba(23, 137, 248, 0.16);
|
||||||
|
--green-halo: rgba(28, 196, 113, 0.16);
|
||||||
|
--orange-halo: rgba(246, 126, 50, 0.14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--tile: rgba(16, 37, 51, 0.28);
|
||||||
|
--tile-strong: rgba(52, 78, 96, 0.31);
|
||||||
|
--tile-line: rgba(203, 234, 248, 0.25);
|
||||||
|
--tile-line-soft: rgba(114, 176, 207, 0.16);
|
||||||
|
--panel-glass: rgba(7, 22, 33, 0.48);
|
||||||
|
--blue-halo: rgba(29, 153, 245, 0.22);
|
||||||
|
--green-halo: rgba(27, 210, 121, 0.19);
|
||||||
|
--orange-halo: rgba(246, 125, 46, 0.18);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background-color: #b7d5e3;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] body {
|
||||||
|
background-color: #091722;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
inset: -1.25rem;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(118deg, rgba(180,220,239,.15), rgba(238,247,251,.025) 45%, rgba(247,226,204,.09)),
|
||||||
|
url("../assets/mountain-lake-light.jpg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
filter: blur(6px) saturate(108%);
|
||||||
|
transform: scale(1.025);
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] body::before {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)),
|
||||||
|
url("../assets/mountain-lake-dark.jpg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
filter: blur(7px) saturate(106%);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
body::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 11% 18%, rgba(255,255,255,.33) 0 .5%, transparent 5%),
|
||||||
|
radial-gradient(circle at 64% 23%, rgba(255,244,218,.28) 0 .6%, transparent 6%),
|
||||||
|
radial-gradient(circle at 91% 79%, rgba(225,250,237,.22) 0 .8%, transparent 7%);
|
||||||
|
filter: blur(11px);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] body::after { opacity: .26; }
|
||||||
|
.ambient::before { opacity: .14; filter: blur(76px); }
|
||||||
|
.ambient::after { opacity: .11; filter: blur(82px); }
|
||||||
|
:root[data-theme="dark"] .ambient::before { opacity: .12; }
|
||||||
|
:root[data-theme="dark"] .ambient::after { opacity: .08; }
|
||||||
|
.climate-panel {
|
||||||
|
background:
|
||||||
|
linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)),
|
||||||
|
var(--panel-glass);
|
||||||
|
box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17);
|
||||||
|
backdrop-filter: blur(13px) saturate(124%);
|
||||||
|
-webkit-backdrop-filter: blur(13px) saturate(124%);
|
||||||
|
}
|
||||||
|
.climate-panel::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%),
|
||||||
|
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%);
|
||||||
|
}
|
||||||
|
.climate-panel::after { opacity: .1; }
|
||||||
|
:root[data-theme="dark"] .climate-panel { box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); }
|
||||||
|
:root[data-theme="dark"] .climate-panel::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); }
|
||||||
|
.glass-tile, .glass-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 35%, transparent 64%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
box-shadow: 0 10px 25px rgba(24,72,105,.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24);
|
||||||
|
backdrop-filter: blur(16px) saturate(127%);
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(127%);
|
||||||
|
}
|
||||||
|
.glass-tile::before, .glass-section::before { opacity: .62; }
|
||||||
|
:root[data-theme="dark"] .glass-tile,
|
||||||
|
:root[data-theme="dark"] .glass-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
box-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055);
|
||||||
|
}
|
||||||
|
.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after { filter: blur(32px); opacity: .48; }
|
||||||
|
.power-card::after { opacity: .38; }
|
||||||
|
.heater-card::after { opacity: .34; }
|
||||||
|
.hero-step.primary { box-shadow: 0 10px 23px rgba(16,103,220,.25), 0 0 15px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.76), inset 0 -2px 5px rgba(0,67,156,.2); }
|
||||||
|
.hero-step:hover { box-shadow: 0 12px 27px rgba(25,112,219,.27), 0 0 19px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.75); }
|
||||||
|
.speed-segments button.active { box-shadow: 0 0 9px rgba(25,143,247,.34), inset 0 1px 0 rgba(255,255,255,.66), inset 0 -2px 4px rgba(0,84,182,.18); }
|
||||||
|
.temperature-range::-webkit-slider-runnable-track { box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); }
|
||||||
|
.temperature-range::-webkit-slider-thumb { box-shadow: 0 0 0 2px rgba(255,255,255,.48), 0 0 12px rgba(29,139,241,.34), 0 4px 10px rgba(17,79,143,.24); }
|
||||||
|
.glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 27px rgba(11,153,81,.22), 0 0 19px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -4px 10px rgba(0,125,62,.14); }
|
||||||
|
.glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 27px rgba(210,91,25,.2), 0 0 18px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.83), inset 0 -4px 10px rgba(168,54,0,.13); }
|
||||||
|
.quality-chip { box-shadow: 0 0 10px rgba(33,203,116,.13), inset 0 1px 0 rgba(255,255,255,.83); }
|
||||||
|
.meter i { box-shadow: 0 0 10px rgba(18,199,104,.35), inset 0 1px 0 rgba(255,255,255,.58); }
|
||||||
|
.filter-meter i { box-shadow: 0 0 10px rgba(31,144,247,.32), inset 0 1px 0 rgba(255,255,255,.54); }
|
||||||
|
.schedule-button.auto, .schedule-button.pause { box-shadow: 0 7px 18px rgba(31,83,119,.12), inset 0 1px 0 rgba(255,255,255,.82); }
|
||||||
|
:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 11px 27px rgba(0,65,139,.4), 0 0 18px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.42), inset 0 -3px 7px rgba(0,27,91,.3); }
|
||||||
|
:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 12px rgba(24,156,255,.4), inset 0 1px 0 rgba(223,248,255,.36); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.4); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.4); }
|
||||||
|
|
||||||
|
/* Natural highlights: state colors tint the glass, but do not paint opaque
|
||||||
|
neon blocks over the photographic background. Heater ON is a healthy green
|
||||||
|
state too; its orange icon still communicates heat. */
|
||||||
|
.speed-card::after, .temperature-card::after { opacity: .18; filter: blur(42px); }
|
||||||
|
.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; filter: blur(44px); }
|
||||||
|
.hero-step.primary { box-shadow: 0 9px 20px rgba(15,92,183,.22), 0 0 9px rgba(48,151,239,.14), inset 0 1px 0 rgba(255,255,255,.78), inset 0 -2px 5px rgba(0,67,156,.18); }
|
||||||
|
.hero-step:hover { box-shadow: 0 11px 23px rgba(20,101,190,.24), 0 0 12px rgba(48,151,239,.16), inset 0 1px 0 rgba(255,255,255,.76); }
|
||||||
|
.speed-segments button.active { box-shadow: 0 0 6px rgba(25,143,247,.24), inset 0 1px 0 rgba(255,255,255,.68), inset 0 -2px 4px rgba(0,84,182,.16); }
|
||||||
|
.glass-switch[aria-pressed="true"].green,
|
||||||
|
.glass-switch[aria-pressed="true"].orange {
|
||||||
|
background: linear-gradient(145deg, rgba(139,244,190,.72), rgba(55,194,122,.48));
|
||||||
|
border-color: rgba(218,255,235,.84);
|
||||||
|
box-shadow: 0 10px 23px rgba(18,143,80,.18), 0 0 11px rgba(38,199,116,.13), inset 0 1px 0 rgba(255,255,255,.82), inset 0 -3px 8px rgba(0,114,57,.1);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .speed-card::after,
|
||||||
|
:root[data-theme="dark"] .temperature-card::after { opacity: .2; }
|
||||||
|
:root[data-theme="dark"] .power-card::after,
|
||||||
|
:root[data-theme="dark"] .heater-card::after { opacity: .18; }
|
||||||
|
:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); }
|
||||||
|
:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange {
|
||||||
|
color: #effff6;
|
||||||
|
border-color: rgba(127,242,182,.4);
|
||||||
|
background: linear-gradient(145deg, rgba(51,190,119,.58), rgba(9,102,59,.55));
|
||||||
|
box-shadow: 0 11px 25px rgba(0,0,0,.25), 0 0 12px rgba(37,207,121,.15), inset 0 1px 0 rgba(218,255,236,.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Final material and icon polish. */
|
||||||
|
.ui-icon {
|
||||||
|
display: block;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.feature-icon { padding: .08rem; filter: drop-shadow(0 0 6px rgba(20,139,244,.2)); text-shadow: none; }
|
||||||
|
.feature-icon.green { filter: drop-shadow(0 0 6px rgba(20,190,104,.24)); }
|
||||||
|
.feature-icon.orange { filter: drop-shadow(0 0 6px rgba(244,105,31,.22)); }
|
||||||
|
.fan-icon { font-size: inherit; }
|
||||||
|
.section-icon { padding: .06rem; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); text-shadow: none; }
|
||||||
|
.section-icon.leaf { filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); }
|
||||||
|
.metric-icon { width: 1.7rem; height: 1.7rem; flex: 0 0 auto; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); text-shadow: none; }
|
||||||
|
.metric-icon.particles { padding: .05rem; }
|
||||||
|
.action-symbol { width: 2rem; height: 2rem; font-size: inherit; }
|
||||||
|
.extra-icon { width: 3.4rem; height: 3.4rem; flex: 0 0 auto; filter: drop-shadow(0 0 7px rgba(33,126,211,.17)); text-shadow: none; }
|
||||||
|
.extra-icon.wind { width: 3.8rem; height: 3.8rem; font-size: inherit; transform: none; }
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--tile: rgba(104, 126, 139, 0.16);
|
||||||
|
--tile-strong: rgba(214, 226, 232, 0.2);
|
||||||
|
--tile-line-soft: rgba(189, 211, 221, 0.38);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] .glass-tile,
|
||||||
|
:root[data-theme="light"] .glass-section {
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
backdrop-filter: blur(14px) saturate(116%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(116%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card:not(.co2-card) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.metric-card:not(.co2-card) .metric-heading { width: 100%; justify-content: center; text-align: left; }
|
||||||
|
.metric-card:not(.co2-card) > strong { margin-top: .7rem; }
|
||||||
|
.metric-card:not(.co2-card) .quality-chip { align-self: center; }
|
||||||
|
.filter-card .filter-line { width: 100%; }
|
||||||
|
|
||||||
|
.hero-step.primary {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)),
|
||||||
|
linear-gradient(145deg, #55b4ff, #1479e8 72%);
|
||||||
|
box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16);
|
||||||
|
}
|
||||||
|
.hero-step.primary:hover { box-shadow: 0 10px 21px rgba(20,101,190,.21), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); }
|
||||||
|
.speed-segments button.active {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%),
|
||||||
|
linear-gradient(90deg, #1685ec, #35afff);
|
||||||
|
box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13);
|
||||||
|
}
|
||||||
|
.glass-switch { overflow: hidden; }
|
||||||
|
.glass-switch[aria-pressed="true"].green,
|
||||||
|
.glass-switch[aria-pressed="true"].orange {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%),
|
||||||
|
linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)),
|
||||||
|
linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43));
|
||||||
|
box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08);
|
||||||
|
}
|
||||||
|
.schedule-button.auto {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 19% -30%, rgba(255,255,255,.55), transparent 44%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
|
||||||
|
linear-gradient(145deg, rgba(211,239,255,.52), rgba(100,179,235,.18));
|
||||||
|
}
|
||||||
|
.schedule-button.pause {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 19% -30%, rgba(255,255,255,.58), transparent 44%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
|
||||||
|
linear-gradient(145deg, rgba(255,231,199,.55), rgba(235,151,76,.18));
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .hero-step.primary {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)),
|
||||||
|
linear-gradient(145deg, #33aaf4, #0869d0 74%);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%),
|
||||||
|
linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)),
|
||||||
|
linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49));
|
||||||
|
box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .schedule-button.auto {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 19% -30%, rgba(208,243,255,.17), transparent 45%),
|
||||||
|
linear-gradient(145deg, rgba(38,125,187,.26), rgba(15,61,91,.25));
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .schedule-button.pause {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 19% -30%, rgba(255,226,199,.16), transparent 45%),
|
||||||
|
linear-gradient(145deg, rgba(167,82,27,.27), rgba(73,40,25,.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1300px) and (min-height: 760px) {
|
||||||
|
.climate-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(18rem, 1.4fr) minmax(11rem, .78fr) minmax(13rem, .9fr) auto;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
.panel-header { padding-bottom: 0; }
|
||||||
|
.primary-controls, .bottom-grid { min-height: 0; height: 100%; }
|
||||||
|
.control-card, .switch-card { min-height: 0; height: 100%; }
|
||||||
|
.content-section { min-height: 0; height: 100%; margin-top: 0; display: flex; flex-direction: column; }
|
||||||
|
.air-grid { min-height: 0; flex: 1; }
|
||||||
|
.metric-card { min-height: 0; height: 100%; }
|
||||||
|
.schedule-layout, .extras-grid { min-height: 0; flex: 1; }
|
||||||
|
.schedule-times, .extra-card { min-height: 0; height: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1700px) {
|
||||||
|
html { font-size: 18px; }
|
||||||
|
.climate-panel { padding: 1.2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1120px) {
|
||||||
|
.primary-controls { grid-template-columns: 1fr 1fr; }
|
||||||
|
.switch-card { min-height: 9rem; }
|
||||||
|
.glass-switch { min-height: 3.8rem; }
|
||||||
|
.air-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.bottom-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
body { padding: .5rem; }
|
||||||
|
.climate-panel { border-radius: 1.1rem; }
|
||||||
|
.panel-header, .identity, .header-tools { flex-wrap: wrap; }
|
||||||
|
.panel-header { align-items: flex-start; }
|
||||||
|
.header-tools { width: 100%; justify-content: flex-start; }
|
||||||
|
.ventilation-badge { min-width: 0; flex: 1; }
|
||||||
|
.primary-controls { grid-template-columns: 1fr; }
|
||||||
|
.control-card, .switch-card { min-height: auto; }
|
||||||
|
.hero-control { margin: 1.4rem 0 .75rem; }
|
||||||
|
.air-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.co2-card, .filter-card { grid-column: 1 / -1; }
|
||||||
|
.schedule-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.identity h1 { width: 100%; }
|
||||||
|
.ventilation-badge small { display: none; }
|
||||||
|
.air-grid, .extras-grid { grid-template-columns: 1fr; }
|
||||||
|
.co2-card, .filter-card { grid-column: auto; }
|
||||||
|
.schedule-times { grid-template-columns: 1fr; }
|
||||||
|
.schedule-times > i { width: 100%; height: 1px; }
|
||||||
|
.micro-settings { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) { .hero-step:hover { transform: none; } }
|
||||||
|
|
||||||
|
/* Schedule controls and editor. */
|
||||||
|
.schedule-title-tools { margin-left: auto; }
|
||||||
|
.schedule-edit-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
min-height: 2.05rem;
|
||||||
|
padding: .36rem .68rem;
|
||||||
|
border: 1px solid var(--tile-line);
|
||||||
|
border-radius: .7rem;
|
||||||
|
color: #126fc4;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% -35%, rgba(255,255,255,.48), transparent 48%),
|
||||||
|
linear-gradient(145deg, rgba(220,241,253,.42), rgba(100,173,220,.13));
|
||||||
|
box-shadow: 0 6px 15px rgba(27,91,135,.1), inset 0 1px 0 rgba(255,255,255,.76);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 750;
|
||||||
|
transition: transform .15s ease, filter .15s ease;
|
||||||
|
}
|
||||||
|
.schedule-edit-button svg { width: 1rem; height: 1rem; }
|
||||||
|
.schedule-edit-button:hover { transform: translateY(-1px); filter: brightness(1.06); }
|
||||||
|
.schedule-button:disabled { cursor: not-allowed; opacity: .5; filter: saturate(.38); box-shadow: none; }
|
||||||
|
.schedule-button:disabled:hover { transform: none; filter: saturate(.38); }
|
||||||
|
.schedule-button[aria-busy="true"] { cursor: wait; }
|
||||||
|
|
||||||
|
#schedule-state.warn {
|
||||||
|
color: #8d3b00;
|
||||||
|
border-color: rgba(247,162,73,.78);
|
||||||
|
background: linear-gradient(145deg, rgba(255,232,190,.82), rgba(240,157,69,.42));
|
||||||
|
box-shadow: 0 0 12px rgba(237,130,39,.13), inset 0 1px 0 rgba(255,255,255,.86);
|
||||||
|
font-size: .66rem;
|
||||||
|
letter-spacing: .07em;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] #schedule-state.warn {
|
||||||
|
color: #ffe2b8;
|
||||||
|
border-color: rgba(255,166,83,.57);
|
||||||
|
background: linear-gradient(145deg, rgba(177,87,24,.52), rgba(95,45,17,.48));
|
||||||
|
box-shadow: 0 0 13px rgba(242,129,38,.15), inset 0 1px 0 rgba(255,228,198,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-dialog {
|
||||||
|
width: min(72rem, calc(100vw - 2rem));
|
||||||
|
max-width: none;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--panel-edge);
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
color: var(--panel-ink);
|
||||||
|
background:
|
||||||
|
linear-gradient(125deg, rgba(255,255,255,.28), transparent 32%, rgba(255,255,255,.06) 72%),
|
||||||
|
rgba(201,229,244,.72);
|
||||||
|
box-shadow: 0 28px 90px rgba(8,45,70,.42), inset 0 1px 0 rgba(255,255,255,.9);
|
||||||
|
backdrop-filter: blur(28px) saturate(135%);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(135%);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.schedule-dialog::backdrop { background: rgba(5,20,31,.46); backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px); }
|
||||||
|
:root[data-theme="dark"] .schedule-dialog { background: linear-gradient(125deg, rgba(205,239,255,.1), transparent 34%), rgba(7,24,36,.88); box-shadow: 0 30px 100px rgba(0,0,0,.64), inset 0 1px 0 rgba(221,243,252,.15); }
|
||||||
|
.schedule-editor { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: calc(100vh - 2rem); }
|
||||||
|
.editor-header, .editor-footer { display: flex; align-items: center; gap: .7rem; padding: .8rem 1rem; }
|
||||||
|
.editor-header { justify-content: space-between; border-bottom: 1px solid var(--tile-line-soft); }
|
||||||
|
.editor-heading { display: flex; align-items: center; gap: .65rem; }
|
||||||
|
.editor-heading > svg { width: 2rem; height: 2rem; color: #1883df; filter: drop-shadow(0 0 5px rgba(24,131,223,.2)); }
|
||||||
|
.editor-heading h2 { margin: 0; font-size: 1.15rem; }
|
||||||
|
.editor-heading small { display: block; margin-top: .15rem; color: var(--panel-muted); font-size: .63rem; }
|
||||||
|
.editor-close { width: 2.2rem; height: 2.2rem; padding: 0; border: 1px solid var(--tile-line); border-radius: .75rem; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: 1.45rem; line-height: 1; }
|
||||||
|
.editor-scroll { display: grid; gap: .7rem; padding: .85rem 1rem 1rem; overflow: auto; }
|
||||||
|
.schedule-enabled { display: flex; align-items: center; gap: .7rem; padding: .72rem .82rem; border: 1px solid var(--tile-line); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(120,169,195,.08)); cursor: pointer; }
|
||||||
|
.schedule-enabled input { width: 1.1rem; height: 1.1rem; accent-color: var(--blue); }
|
||||||
|
.schedule-enabled span { display: grid; gap: .12rem; }
|
||||||
|
.schedule-enabled strong { font-size: .76rem; }
|
||||||
|
.schedule-enabled small { color: var(--panel-muted); font-size: .61rem; }
|
||||||
|
.editor-section { padding: .8rem; border: 1px solid var(--tile-line); border-radius: 1rem; background: linear-gradient(145deg, rgba(255,255,255,.19), rgba(94,148,179,.075)); box-shadow: inset 0 1px 0 rgba(255,255,255,.48); }
|
||||||
|
:root[data-theme="dark"] .editor-section, :root[data-theme="dark"] .schedule-enabled { background: linear-gradient(145deg, rgba(205,236,249,.055), rgba(2,19,30,.12)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); }
|
||||||
|
.editor-section-title { display: flex; align-items: end; justify-content: space-between; gap: .75rem; margin-bottom: .65rem; }
|
||||||
|
.editor-section-title h3 { margin: 0; font-size: .86rem; }
|
||||||
|
.editor-section-title small { display: block; margin-top: .13rem; color: var(--panel-muted); font-size: .6rem; }
|
||||||
|
.day-assignments { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .42rem; }
|
||||||
|
.day-assignment { display: grid; gap: .3rem; color: var(--panel-muted); font-size: .58rem; font-weight: 700; }
|
||||||
|
.day-assignment select, .template-toolbar select, .point-editor input, .point-editor select {
|
||||||
|
min-width: 0;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: .38rem .48rem;
|
||||||
|
border: 1px solid var(--tile-line);
|
||||||
|
border-radius: .62rem;
|
||||||
|
color: var(--panel-ink);
|
||||||
|
background: rgba(245,251,253,.48);
|
||||||
|
outline: none;
|
||||||
|
font-size: .66rem;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .day-assignment select,
|
||||||
|
:root[data-theme="dark"] .template-toolbar select,
|
||||||
|
:root[data-theme="dark"] .point-editor input,
|
||||||
|
:root[data-theme="dark"] .point-editor select { background: rgba(16,38,52,.72); }
|
||||||
|
.day-assignment select:focus, .template-toolbar select:focus, .point-editor input:focus, .point-editor select:focus { border-color: rgba(45,151,230,.7); box-shadow: 0 0 0 3px rgba(34,145,226,.11); }
|
||||||
|
.template-toolbar > label { display: grid; gap: .25rem; min-width: 10rem; color: var(--panel-muted); font-size: .57rem; font-weight: 700; }
|
||||||
|
.template-actions { display: flex; align-items: end; gap: .35rem; }
|
||||||
|
.template-actions button { min-height: 2.25rem; padding: .4rem .65rem; border: 1px solid var(--tile-line); border-radius: .62rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .61rem; font-weight: 700; }
|
||||||
|
.template-actions #delete-template { color: var(--red); border-color: rgba(223,91,105,.28); background: var(--red-soft); }
|
||||||
|
.template-actions button:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.schedule-points { display: grid; gap: .55rem; }
|
||||||
|
.point-editor { padding: .68rem; border: 1px solid var(--tile-line); border-radius: .85rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(116,160,183,.065)); box-shadow: inset 0 1px 0 rgba(255,255,255,.45); }
|
||||||
|
:root[data-theme="dark"] .point-editor { background: linear-gradient(145deg, rgba(197,231,247,.055), rgba(2,18,29,.13)); box-shadow: inset 0 1px 0 rgba(224,246,255,.06); }
|
||||||
|
.point-header { display: grid; grid-template-columns: auto minmax(7rem, .7fr) minmax(9rem, 1fr) auto; align-items: end; gap: .55rem; }
|
||||||
|
.point-number { align-self: center; display: grid; place-items: center; width: 1.85rem; height: 1.85rem; border-radius: 50%; color: #1478cc; background: var(--blue-soft); font-size: .62rem; font-weight: 800; }
|
||||||
|
.point-header label, .point-setting { display: grid; gap: .25rem; color: var(--panel-muted); font-size: .56rem; font-weight: 700; }
|
||||||
|
.remove-point { width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(223,91,105,.32); border-radius: .62rem; color: var(--red); background: var(--red-soft); cursor: pointer; font-size: 1.1rem; }
|
||||||
|
.point-settings { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .48rem; margin-top: .55rem; }
|
||||||
|
.point-editor.auto [data-set-only] { display: none; }
|
||||||
|
.point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.empty-points { padding: 1.4rem; border: 1px dashed var(--tile-line); border-radius: .8rem; color: var(--panel-muted); text-align: center; font-size: .68rem; }
|
||||||
|
.add-schedule-point { width: 100%; min-height: 2.55rem; margin-top: .6rem; border: 1px dashed rgba(37,131,205,.55); border-radius: .75rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .68rem; font-weight: 750; }
|
||||||
|
.editor-footer { border-top: 1px solid var(--tile-line-soft); justify-content: flex-end; }
|
||||||
|
.editor-status { margin-right: auto; color: var(--panel-muted); font-size: .63rem; }
|
||||||
|
.editor-status.error { color: var(--red); }
|
||||||
|
.editor-button { min-height: 2.5rem; padding: .58rem .9rem; border-radius: .75rem; cursor: pointer; font-size: .69rem; font-weight: 750; }
|
||||||
|
.editor-button.secondary { border: 1px solid var(--tile-line); color: var(--panel-ink); background: var(--tile); }
|
||||||
|
.editor-button.primary { border: 1px solid rgba(182,226,255,.76); color: white; background: radial-gradient(circle at 20% -30%, rgba(255,255,255,.5), transparent 48%), linear-gradient(145deg, #3aa9eb, #1476c9); box-shadow: 0 7px 17px rgba(18,102,170,.2), inset 0 1px 0 rgba(255,255,255,.75); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.day-assignments { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
|
.point-settings { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.schedule-dialog { width: calc(100vw - .7rem); max-height: calc(100vh - .7rem); border-radius: 1rem; }
|
||||||
|
.schedule-editor { max-height: calc(100vh - .7rem); }
|
||||||
|
.editor-header, .editor-footer { padding: .7rem; }
|
||||||
|
.editor-scroll { padding: .7rem; }
|
||||||
|
.day-assignments { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.editor-section-title { align-items: stretch; flex-direction: column; }
|
||||||
|
.template-toolbar > label { min-width: 0; }
|
||||||
|
.template-actions { align-items: stretch; }
|
||||||
|
.template-actions button { flex: 1; }
|
||||||
|
.point-header { grid-template-columns: auto 1fr auto; }
|
||||||
|
.point-header .point-action { grid-column: 2 / -1; }
|
||||||
|
.point-settings, .point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.editor-footer { flex-wrap: wrap; }
|
||||||
|
.editor-status { width: 100%; }
|
||||||
|
.schedule-edit-button span { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CO2 state drives both the label and the meter, so the visual state cannot
|
||||||
|
disagree with the air-quality text returned by airQuality(). */
|
||||||
|
.co2-card {
|
||||||
|
--air-color: #7896ac;
|
||||||
|
--air-glow: rgba(93, 140, 171, .22);
|
||||||
|
--air-track: rgba(181, 203, 200, .28);
|
||||||
|
}
|
||||||
|
.co2-card[data-air-tone="good"] {
|
||||||
|
--air-color: #1fbd69;
|
||||||
|
--air-glow: rgba(18, 199, 104, .34);
|
||||||
|
}
|
||||||
|
.co2-card[data-air-tone="warn"] {
|
||||||
|
--air-color: #efa12b;
|
||||||
|
--air-glow: rgba(244, 161, 42, .35);
|
||||||
|
}
|
||||||
|
.co2-card[data-air-tone="bad"] {
|
||||||
|
--air-color: #e8495c;
|
||||||
|
--air-glow: rgba(235, 74, 83, .36);
|
||||||
|
}
|
||||||
|
.co2-card .co2-meter {
|
||||||
|
height: .84rem;
|
||||||
|
border-color: rgba(255,255,255,.82);
|
||||||
|
background-color: var(--air-track);
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.15), transparent 52%, rgba(0,20,36,.045));
|
||||||
|
box-shadow: inset 0 1px 3px rgba(20,61,78,.14), 0 1px 4px rgba(8,43,72,.07);
|
||||||
|
transition: background-color .65s ease;
|
||||||
|
}
|
||||||
|
.co2-card .co2-meter i {
|
||||||
|
background-color: var(--air-color);
|
||||||
|
background-image: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.045) 48%, rgba(0,35,20,.1));
|
||||||
|
box-shadow: 0 0 8px var(--air-glow), inset 0 1px 0 rgba(255,255,255,.58), inset 0 -1px 2px rgba(0,34,19,.1);
|
||||||
|
transition: width .35s ease, background-color .65s ease, box-shadow .65s ease;
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .co2-card .co2-meter { border-color: rgba(205,235,248,.3); box-shadow: inset 0 1px 4px rgba(0,0,0,.27), 0 1px 5px rgba(0,0,0,.12); }
|
||||||
|
.co2-card .quality-chip.muted {
|
||||||
|
color: var(--panel-muted);
|
||||||
|
border-color: var(--tile-line-soft);
|
||||||
|
background: rgba(199,222,235,.2);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,.55);
|
||||||
|
}
|
||||||
|
.co2-card .quality-chip.good {
|
||||||
|
color: #076d3d;
|
||||||
|
border-color: rgba(109,237,167,.65);
|
||||||
|
background: linear-gradient(145deg, rgba(179,255,214,.58), rgba(92,218,151,.39));
|
||||||
|
box-shadow: 0 0 9px rgba(33,203,116,.12), inset 0 1px 0 rgba(255,255,255,.82);
|
||||||
|
}
|
||||||
|
.co2-card .quality-chip.warn {
|
||||||
|
color: #9c5513;
|
||||||
|
border-color: rgba(244,183,106,.67);
|
||||||
|
background: linear-gradient(145deg, rgba(255,232,188,.56), rgba(239,168,80,.34));
|
||||||
|
box-shadow: 0 0 9px rgba(235,153,53,.11), inset 0 1px 0 rgba(255,255,255,.8);
|
||||||
|
}
|
||||||
|
.co2-card .quality-chip.bad {
|
||||||
|
color: #a62f3d;
|
||||||
|
border-color: rgba(242,135,147,.62);
|
||||||
|
background: linear-gradient(145deg, rgba(255,209,216,.53), rgba(225,105,120,.3));
|
||||||
|
box-shadow: 0 0 9px rgba(224,83,101,.12), inset 0 1px 0 rgba(255,255,255,.78);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] .co2-card { --air-track: rgba(8,28,39,.38); }
|
||||||
|
:root[data-theme="dark"] .co2-card[data-air-tone="good"] { --air-glow: rgba(30,224,128,.3); }
|
||||||
|
:root[data-theme="dark"] .co2-card[data-air-tone="warn"] { --air-glow: rgba(255,176,49,.3); }
|
||||||
|
:root[data-theme="dark"] .co2-card[data-air-tone="bad"] { --air-glow: rgba(255,82,102,.32); }
|
||||||
|
:root[data-theme="dark"] .co2-card .quality-chip.good { color: #b9ffda; background: linear-gradient(145deg, rgba(45,186,113,.35), rgba(11,101,60,.3)); }
|
||||||
|
:root[data-theme="dark"] .co2-card .quality-chip.warn { color: #ffd59e; background: linear-gradient(145deg, rgba(176,115,30,.34), rgba(91,58,15,.3)); }
|
||||||
|
:root[data-theme="dark"] .co2-card .quality-chip.bad { color: #ffc0c8; background: linear-gradient(145deg, rgba(174,58,75,.34), rgba(91,25,37,.31)); }
|
||||||
|
:root[data-theme="dark"] .co2-card .quality-chip.muted { color: var(--panel-muted); background: rgba(42,70,88,.25); }
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
:root {
|
||||||
|
--widget-ink: #08265d;
|
||||||
|
--widget-muted: #315d91;
|
||||||
|
--widget-glass: rgba(200, 228, 244, 0.21);
|
||||||
|
--widget-edge: rgba(255, 255, 255, 0.9);
|
||||||
|
--tile: rgba(104, 126, 139, 0.16);
|
||||||
|
--tile-strong: rgba(214, 226, 232, 0.2);
|
||||||
|
--tile-edge: rgba(255, 255, 255, 0.82);
|
||||||
|
--tile-edge-soft: rgba(189, 211, 221, 0.38);
|
||||||
|
--tile-shadow: 0 10px 25px rgba(24, 72, 105, 0.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24);
|
||||||
|
--blue-halo: rgba(23, 137, 248, 0.16);
|
||||||
|
--green-halo: rgba(28, 196, 113, 0.16);
|
||||||
|
--orange-halo: rgba(246, 126, 50, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--widget-ink: #edf8ff;
|
||||||
|
--widget-muted: #9fc5df;
|
||||||
|
--widget-glass: rgba(7, 22, 33, 0.49);
|
||||||
|
--widget-edge: rgba(187, 225, 244, 0.3);
|
||||||
|
--tile: rgba(16, 37, 51, 0.3);
|
||||||
|
--tile-strong: rgba(52, 78, 96, 0.33);
|
||||||
|
--tile-edge: rgba(203, 234, 248, 0.25);
|
||||||
|
--tile-edge-soft: rgba(114, 176, 207, 0.16);
|
||||||
|
--tile-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055);
|
||||||
|
--blue-halo: rgba(29, 153, 245, 0.22);
|
||||||
|
--green-halo: rgba(27, 210, 121, 0.19);
|
||||||
|
--orange-halo: rgba(246, 125, 46, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { min-width: 280px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: clamp(.5rem, 2.2vw, 1rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--widget-ink);
|
||||||
|
background-color: #b7d5e3;
|
||||||
|
overflow-x: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body {
|
||||||
|
background-color: #091722;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before,
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
inset: -1.25rem;
|
||||||
|
z-index: -2;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(118deg, rgba(180,220,239,.15), rgba(238,247,251,.025) 45%, rgba(247,226,204,.09)),
|
||||||
|
url("../assets/mountain-lake-light.jpg");
|
||||||
|
background-position: center;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
filter: blur(6px) saturate(108%);
|
||||||
|
transform: scale(1.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 11% 18%, rgba(255,255,255,.32) 0 .5%, transparent 5%),
|
||||||
|
radial-gradient(circle at 64% 23%, rgba(255,244,218,.27) 0 .6%, transparent 6%),
|
||||||
|
radial-gradient(circle at 91% 79%, rgba(225,250,237,.21) 0 .8%, transparent 7%);
|
||||||
|
filter: blur(11px);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body::before {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)),
|
||||||
|
url("../assets/mountain-lake-dark.jpg");
|
||||||
|
filter: blur(7px) saturate(106%);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] body::after { opacity: .25; }
|
||||||
|
|
||||||
|
.ambient::before { width: 32rem; height: 32rem; left: -13rem; top: -14rem; background: #c9f2ff; opacity: .14; filter: blur(76px); }
|
||||||
|
.ambient::after { width: 34rem; height: 34rem; right: -14rem; bottom: -16rem; background: #ebd5a7; opacity: .11; filter: blur(82px); }
|
||||||
|
:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .12; }
|
||||||
|
:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .08; }
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: min(100%, 31.5rem);
|
||||||
|
display: grid;
|
||||||
|
gap: .65rem;
|
||||||
|
padding: clamp(.65rem, 2.2vw, .9rem);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--widget-edge);
|
||||||
|
outline: 1px solid rgba(255,255,255,.48);
|
||||||
|
outline-offset: -5px;
|
||||||
|
border-radius: 1.55rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)),
|
||||||
|
var(--widget-glass);
|
||||||
|
box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17);
|
||||||
|
backdrop-filter: blur(13px) saturate(124%);
|
||||||
|
-webkit-backdrop-filter: blur(13px) saturate(124%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget::before,
|
||||||
|
.widget::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget::before {
|
||||||
|
background:
|
||||||
|
linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%),
|
||||||
|
radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget::after {
|
||||||
|
opacity: .1;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E");
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget > * { position: relative; z-index: 1; }
|
||||||
|
:root[data-theme="dark"] .widget { outline-color: rgba(203,235,250,.11); box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); }
|
||||||
|
:root[data-theme="dark"] .widget::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); }
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: .55rem .7rem;
|
||||||
|
padding: .15rem .25rem .05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity { min-width: 0; display: flex; align-items: center; gap: .7rem; }
|
||||||
|
.brand-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.9rem;
|
||||||
|
height: 2.9rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid rgba(255,255,255,.82);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: #147bd5;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% 2%, rgba(255,255,255,.62), transparent 43%),
|
||||||
|
linear-gradient(145deg, rgba(217,242,255,.58), rgba(73,159,222,.2));
|
||||||
|
box-shadow: 0 8px 19px rgba(19,101,160,.13), inset 0 1px 0 rgba(255,255,255,.88);
|
||||||
|
}
|
||||||
|
.brand-icon svg { width: 1.75rem; height: 1.75rem; filter: drop-shadow(0 0 5px rgba(20,123,213,.18)); }
|
||||||
|
.identity-copy { min-width: 0; display: block; }
|
||||||
|
.title-row { display: flex; align-items: center; gap: .55rem; min-width: 0; }
|
||||||
|
.identity h1 { margin: 0; font-size: 1.62rem; line-height: 1; letter-spacing: -.045em; white-space: nowrap; }
|
||||||
|
.identity-copy > small { display: block; margin-top: .28rem; color: var(--widget-muted); font-size: .7rem; }
|
||||||
|
.widget .pill { min-height: 1.75rem; padding: .27rem .58rem; border-color: rgba(255,255,255,.56); font-size: .59rem; box-shadow: inset 0 1px 0 rgba(255,255,255,.62), 0 5px 14px rgba(26,75,108,.08); backdrop-filter: blur(12px); }
|
||||||
|
.widget .pill::before { width: .36rem; height: .36rem; box-shadow: 0 0 7px currentColor; }
|
||||||
|
|
||||||
|
.theme-button,
|
||||||
|
.open-section {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--tile-edge);
|
||||||
|
color: var(--widget-ink);
|
||||||
|
background: linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
box-shadow: var(--tile-shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
transition: transform .15s ease, filter .15s ease;
|
||||||
|
}
|
||||||
|
.theme-button { width: 2.55rem; height: 2.55rem; border-radius: .9rem; }
|
||||||
|
.theme-button svg { width: 1.18rem; height: 1.18rem; }
|
||||||
|
.theme-button:hover, .open-section:hover { transform: translateY(-1px); filter: brightness(1.07); }
|
||||||
|
.theme-button:active, .open-section:active { transform: scale(.97); }
|
||||||
|
.status-row { grid-column: 1 / -1; display: flex; align-items: center; gap: .55rem; min-width: 0; }
|
||||||
|
.mode-detail { min-width: 0; color: var(--widget-muted); font-size: .66rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.widget-tile {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--tile-edge);
|
||||||
|
outline: 1px solid var(--tile-edge-soft);
|
||||||
|
outline-offset: -3px;
|
||||||
|
border-radius: 1.15rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%),
|
||||||
|
linear-gradient(145deg, var(--tile-strong), var(--tile));
|
||||||
|
box-shadow: var(--tile-shadow);
|
||||||
|
backdrop-filter: blur(14px) saturate(116%);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(116%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-tile::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: .62;
|
||||||
|
background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08));
|
||||||
|
}
|
||||||
|
.widget-tile > * { position: relative; z-index: 1; }
|
||||||
|
:root[data-theme="dark"] .widget-tile { background: linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%), linear-gradient(145deg, var(--tile-strong), var(--tile)); backdrop-filter: blur(16px) saturate(127%); -webkit-backdrop-filter: blur(16px) saturate(127%); }
|
||||||
|
|
||||||
|
.control-card { min-height: 11.4rem; padding: .9rem 1rem .85rem; display: flex; flex-direction: column; }
|
||||||
|
.control-card::after,
|
||||||
|
.switch-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
width: 12rem;
|
||||||
|
height: 12rem;
|
||||||
|
right: -5rem;
|
||||||
|
bottom: -7rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
filter: blur(42px);
|
||||||
|
opacity: .18;
|
||||||
|
}
|
||||||
|
.speed-card::after { background: var(--blue-halo); }
|
||||||
|
.temperature-card::after { width: 17rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); }
|
||||||
|
.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; }
|
||||||
|
|
||||||
|
.card-title { display: flex; align-items: flex-start; gap: .65rem; }
|
||||||
|
.card-title h2, .switch-title h2, .section-title h2 { margin: 0; font-size: .96rem; line-height: 1.2; letter-spacing: -.015em; }
|
||||||
|
.card-title p { margin: .16rem 0 0; color: var(--widget-muted); font-size: .66rem; }
|
||||||
|
.ui-icon { display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.feature-icon { width: 1.95rem; height: 1.95rem; flex: 0 0 auto; padding: .06rem; color: #1389f4; filter: drop-shadow(0 0 5px rgba(20,139,244,.2)); }
|
||||||
|
|
||||||
|
.hero-control { display: grid; grid-template-columns: 3.45rem 1fr 3.45rem; align-items: center; gap: .8rem; margin: 1rem 0 .72rem; }
|
||||||
|
.hero-value { text-align: center; font-size: 3rem; line-height: 1; letter-spacing: -.065em; font-variant-numeric: tabular-nums; }
|
||||||
|
.hero-step {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 3.45rem;
|
||||||
|
height: 3.45rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1.5px solid rgba(255,255,255,.84);
|
||||||
|
border-radius: .95rem;
|
||||||
|
color: #176dbb;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 5%, rgba(255,255,255,.48), transparent 40%),
|
||||||
|
linear-gradient(145deg, rgba(245,252,255,.4), rgba(191,224,244,.18));
|
||||||
|
box-shadow: 0 8px 19px rgba(35,92,132,.13), inset 0 1px 0 rgba(255,255,255,.88), 0 0 0 1px rgba(185,226,248,.2);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform .15s ease, filter .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
.hero-step.primary {
|
||||||
|
color: white;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)),
|
||||||
|
linear-gradient(145deg, #55b4ff, #1479e8 72%);
|
||||||
|
box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16);
|
||||||
|
}
|
||||||
|
.hero-step:hover { transform: translateY(-1px); filter: brightness(1.07); box-shadow: 0 10px 21px rgba(20,101,190,.2), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); }
|
||||||
|
.hero-step:active { transform: scale(.97); }
|
||||||
|
|
||||||
|
.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: .18rem; }
|
||||||
|
.speed-segments button {
|
||||||
|
height: .9rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(255,255,255,.82);
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: linear-gradient(180deg, rgba(173,207,229,.3), rgba(98,151,190,.18));
|
||||||
|
box-shadow: inset 0 1px 2px rgba(27,70,105,.07), 0 2px 5px rgba(32,86,126,.07);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .12s ease, box-shadow .12s ease, transform .12s ease;
|
||||||
|
}
|
||||||
|
.speed-segments button:hover { transform: translateY(-1px); }
|
||||||
|
.speed-segments button.active {
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%), linear-gradient(90deg, #1685ec, #35afff);
|
||||||
|
box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13);
|
||||||
|
}
|
||||||
|
.scale-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: .34rem; padding: 0 .3rem; color: var(--widget-muted); font-size: .62rem; }
|
||||||
|
|
||||||
|
.temperature-range { width: 100%; height: 1.45rem; margin: 0; appearance: none; background: transparent; cursor: pointer; }
|
||||||
|
.temperature-range::-webkit-slider-runnable-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); }
|
||||||
|
.temperature-range::-moz-range-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); }
|
||||||
|
.temperature-range::-moz-range-progress { background: transparent; }
|
||||||
|
.temperature-range::-webkit-slider-thumb { width: 1.6rem; height: 1.6rem; margin-top: -.49rem; appearance: none; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); }
|
||||||
|
.temperature-range::-moz-range-thumb { width: 1.08rem; height: 1.08rem; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); }
|
||||||
|
|
||||||
|
.switch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; }
|
||||||
|
.switch-card { min-width: 0; min-height: 8.15rem; padding: .82rem; display: flex; flex-direction: column; gap: .72rem; }
|
||||||
|
.switch-title { display: flex; align-items: center; gap: .5rem; min-width: 0; }
|
||||||
|
.switch-title > span { min-width: 0; }
|
||||||
|
.switch-title small { display: block; margin-top: .12rem; color: var(--widget-muted); font-size: .58rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.switch-icon { width: 1.75rem; height: 1.75rem; flex: 0 0 auto; }
|
||||||
|
.switch-icon.green { color: #08a95a; filter: drop-shadow(0 0 5px rgba(20,190,104,.23)); }
|
||||||
|
.switch-icon.orange { color: #f06419; filter: drop-shadow(0 0 5px rgba(244,105,31,.2)); }
|
||||||
|
.glass-switch {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 3.35rem;
|
||||||
|
margin-top: auto;
|
||||||
|
border: 1.5px solid rgba(255,255,255,.86);
|
||||||
|
border-radius: .95rem;
|
||||||
|
color: var(--widget-ink);
|
||||||
|
background: rgba(199,222,235,.24);
|
||||||
|
box-shadow: var(--tile-shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 850;
|
||||||
|
text-shadow: 0 1px 0 rgba(255,255,255,.3);
|
||||||
|
transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease;
|
||||||
|
}
|
||||||
|
.glass-switch:hover { transform: translateY(-1px); filter: brightness(1.06); }
|
||||||
|
.glass-switch:active { transform: scale(.98); }
|
||||||
|
.glass-switch[aria-pressed="true"].green,
|
||||||
|
.glass-switch[aria-pressed="true"].orange {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%),
|
||||||
|
linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)),
|
||||||
|
linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43));
|
||||||
|
border-color: rgba(218,255,235,.84);
|
||||||
|
box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card { padding: .82rem; }
|
||||||
|
.section-title { display: flex; align-items: center; justify-content: space-between; gap: .65rem; }
|
||||||
|
.section-title > span { display: flex; align-items: center; gap: .5rem; }
|
||||||
|
.section-icon { width: 1.75rem; height: 1.75rem; color: #207bcc; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); }
|
||||||
|
.section-icon.leaf { color: #069c51; transform: rotate(-18deg); filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); }
|
||||||
|
.open-section { width: 2rem; height: 2rem; padding: 0; border-radius: .7rem; color: #147bd5; font-size: .96rem; }
|
||||||
|
|
||||||
|
.co2-summary { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .6rem; margin: .8rem .1rem .52rem; }
|
||||||
|
.co2-label { color: var(--widget-muted); font-size: .74rem; font-weight: 750; }
|
||||||
|
.co2-summary strong { text-align: center; font-size: 2rem; line-height: 1; letter-spacing: -.045em; }
|
||||||
|
.co2-summary strong small { color: var(--widget-muted); font-size: .62rem; font-weight: 650; letter-spacing: 0; }
|
||||||
|
.quality-chip { display: inline-flex; justify-content: center; padding: .32rem .62rem; border: 1px solid var(--tile-edge-soft); border-radius: 999px; background: rgba(199,222,235,.2); color: var(--widget-muted); font-size: .6rem; font-weight: 750; white-space: nowrap; }
|
||||||
|
.quality-chip.good { color: #076d3d; border-color: rgba(109,237,167,.65); background: linear-gradient(145deg, rgba(179,255,214,.58), rgba(92,218,151,.39)); box-shadow: 0 0 9px rgba(33,203,116,.12), inset 0 1px 0 rgba(255,255,255,.82); }
|
||||||
|
.quality-chip.warn { color: #9c5513; border-color: rgba(244,183,106,.67); background: linear-gradient(145deg, rgba(255,232,188,.56), rgba(239,168,80,.34)); }
|
||||||
|
.quality-chip.bad { color: #a62f3d; border-color: rgba(242,135,147,.62); background: linear-gradient(145deg, rgba(255,209,216,.53), rgba(225,105,120,.3)); }
|
||||||
|
:root[data-theme="dark"] .quality-chip.good { color: #b9ffda; }
|
||||||
|
:root[data-theme="dark"] .quality-chip.warn { color: #ffd59e; }
|
||||||
|
:root[data-theme="dark"] .quality-chip.bad { color: #ffc0c8; }
|
||||||
|
|
||||||
|
.air-card {
|
||||||
|
--air-color: #7896ac;
|
||||||
|
--air-glow: rgba(93, 140, 171, .2);
|
||||||
|
--air-track: rgba(181, 203, 200, .28);
|
||||||
|
}
|
||||||
|
.air-card[data-air-tone="good"] {
|
||||||
|
--air-color: #1fbd69;
|
||||||
|
--air-glow: rgba(18, 199, 104, .34);
|
||||||
|
}
|
||||||
|
.air-card[data-air-tone="warn"] {
|
||||||
|
--air-color: #efa12b;
|
||||||
|
--air-glow: rgba(244, 161, 42, .35);
|
||||||
|
}
|
||||||
|
.air-card[data-air-tone="bad"] {
|
||||||
|
--air-color: #e8495c;
|
||||||
|
--air-glow: rgba(235, 74, 83, .36);
|
||||||
|
}
|
||||||
|
.co2-meter { height: .72rem; border: 1px solid rgba(255,255,255,.82); border-radius: 999px; background-color: var(--air-track); background-image: linear-gradient(180deg, rgba(255,255,255,.15), transparent 52%, rgba(0,20,36,.045)); box-shadow: inset 0 1px 3px rgba(20,61,78,.14), 0 1px 4px rgba(8,43,72,.07); overflow: hidden; transition: background-color .65s ease; }
|
||||||
|
.co2-meter i { display: block; width: 0; height: 100%; border-radius: inherit; background-color: var(--air-color); background-image: linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,.045) 48%, rgba(0,35,20,.1)); box-shadow: 0 0 8px var(--air-glow), inset 0 1px 0 rgba(255,255,255,.58), inset 0 -1px 2px rgba(0,34,19,.1); transition: width .35s ease, background-color .65s ease, box-shadow .65s ease; }
|
||||||
|
.meter-labels { display: flex; justify-content: space-between; margin-top: .27rem; padding: 0 .2rem; color: rgba(22,69,108,.78); font-size: .58rem; font-weight: 650; text-shadow: 0 1px 0 rgba(255,255,255,.48); }
|
||||||
|
:root[data-theme="dark"] .meter-labels { color: rgba(211,235,248,.76); text-shadow: 0 1px 2px rgba(0,0,0,.58); }
|
||||||
|
.mini-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: .52rem; margin-top: .68rem; }
|
||||||
|
.mini-metric { min-width: 0; min-height: 4.35rem; display: flex; align-items: center; justify-content: center; gap: .5rem; padding: .62rem .5rem; border: 1px solid var(--tile-edge-soft); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.13), rgba(107,151,177,.055)); box-shadow: inset 0 1px 0 rgba(255,255,255,.33); text-align: center; }
|
||||||
|
.metric-icon { width: 1.55rem; height: 1.55rem; flex: 0 0 auto; color: #137edc; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); }
|
||||||
|
.mini-metric span { min-width: 0; }
|
||||||
|
.mini-metric small { display: block; color: var(--widget-muted); font-size: .58rem; }
|
||||||
|
.mini-metric strong { display: block; margin-top: .2rem; font-size: 1.35rem; line-height: 1; letter-spacing: -.035em; }
|
||||||
|
|
||||||
|
.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; gap: .7rem; margin-top: .75rem; padding: .15rem .25rem .25rem; }
|
||||||
|
.schedule-times > span { display: grid; justify-items: center; gap: .18rem; text-align: center; }
|
||||||
|
.schedule-times small { color: var(--widget-muted); font-size: .6rem; }
|
||||||
|
.schedule-times strong { font-size: 1.35rem; letter-spacing: -.035em; }
|
||||||
|
.schedule-times > i { width: 1px; height: 2.75rem; background: var(--tile-edge-soft); }
|
||||||
|
|
||||||
|
.open-panel {
|
||||||
|
min-height: 3.25rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: .65rem;
|
||||||
|
padding: .65rem .9rem;
|
||||||
|
border: 1px solid rgba(194,232,255,.84);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: white;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% -35%, rgba(255,255,255,.52), transparent 47%),
|
||||||
|
linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%),
|
||||||
|
linear-gradient(145deg, rgba(60,174,239,.92), rgba(17,113,194,.88));
|
||||||
|
box-shadow: 0 9px 20px rgba(15,92,183,.2), 0 0 9px rgba(48,151,239,.11), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -3px 7px rgba(0,67,156,.15);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 760;
|
||||||
|
transition: transform .15s ease, filter .15s ease, box-shadow .15s ease;
|
||||||
|
}
|
||||||
|
.open-panel svg { width: 1.45rem; height: 1.45rem; }
|
||||||
|
.open-panel b { font-size: 1rem; }
|
||||||
|
.open-panel:hover { transform: translateY(-1px); filter: brightness(1.05); box-shadow: 0 11px 23px rgba(20,101,190,.23), 0 0 11px rgba(48,151,239,.13), inset 0 1px 0 rgba(255,255,255,.86); }
|
||||||
|
.open-panel:active { transform: scale(.99); }
|
||||||
|
.demo-note { color: var(--widget-muted); text-align: center; font-size: .58rem; }
|
||||||
|
|
||||||
|
:root[data-theme="dark"] .brand-icon { border-color: rgba(193,228,245,.25); background: radial-gradient(circle at 25% 2%, rgba(208,243,255,.16), transparent 43%), linear-gradient(145deg, rgba(38,125,187,.27), rgba(15,61,91,.26)); }
|
||||||
|
:root[data-theme="dark"] .hero-step { color: #76c7fa; border-color: rgba(192,226,244,.25); background: radial-gradient(circle at 24% 5%, rgba(213,240,252,.11), transparent 40%), linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); box-shadow: 0 9px 21px rgba(0,0,0,.23), inset 0 1px 0 rgba(226,246,255,.12); }
|
||||||
|
:root[data-theme="dark"] .hero-step.primary { color: white; background: radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%), linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)), linear-gradient(145deg, #33aaf4, #0869d0 74%); box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); }
|
||||||
|
:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); }
|
||||||
|
:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.48); background: linear-gradient(180deg, rgba(216,246,255,.19), transparent 48%), linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); }
|
||||||
|
:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); }
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green,
|
||||||
|
:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #effff6; border-color: rgba(127,242,182,.4); background: radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%), linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)), linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49)); box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16); }
|
||||||
|
:root[data-theme="dark"] .air-card { --air-track: rgba(8,28,39,.38); }
|
||||||
|
:root[data-theme="dark"] .air-card[data-air-tone="good"] { --air-glow: rgba(30, 224, 128, .3); }
|
||||||
|
:root[data-theme="dark"] .air-card[data-air-tone="warn"] { --air-glow: rgba(255, 176, 49, .3); }
|
||||||
|
:root[data-theme="dark"] .air-card[data-air-tone="bad"] { --air-glow: rgba(255, 82, 102, .32); }
|
||||||
|
:root[data-theme="dark"] .co2-meter { border-color: rgba(205,235,248,.3); box-shadow: inset 0 1px 4px rgba(0,0,0,.27), 0 1px 5px rgba(0,0,0,.12); }
|
||||||
|
:root[data-theme="dark"] .mini-metric { background: linear-gradient(145deg, rgba(196,231,247,.055), rgba(3,24,37,.08)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); }
|
||||||
|
|
||||||
|
/* Wide monitor layout. The same markup stays vertical on narrow screens, so
|
||||||
|
controls and API behaviour cannot drift between two widget versions. */
|
||||||
|
:root[data-widget-layout="horizontal"] body {
|
||||||
|
padding: .36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-widget-layout="horizontal"] .widget {
|
||||||
|
width: min(100%, 76rem);
|
||||||
|
max-width: none;
|
||||||
|
gap: .48rem;
|
||||||
|
padding: .58rem;
|
||||||
|
border-radius: 1.35rem;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, .8fr) minmax(0, .8fr);
|
||||||
|
grid-template-areas:
|
||||||
|
"header header header header"
|
||||||
|
"speed temperature power heater"
|
||||||
|
"air air schedule schedule"
|
||||||
|
"open open open open"
|
||||||
|
"demo demo demo demo";
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .widget-header {
|
||||||
|
grid-area: header;
|
||||||
|
min-height: 2.6rem;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: .35rem .55rem;
|
||||||
|
padding: 0 .12rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .widget-header .identity { grid-column: 1; grid-row: 1; }
|
||||||
|
:root[data-widget-layout="horizontal"] .widget-header .status-row { grid-column: 2; grid-row: 1; }
|
||||||
|
:root[data-widget-layout="horizontal"] .widget-header .theme-button { grid-column: 3; grid-row: 1; }
|
||||||
|
:root[data-widget-layout="horizontal"] .identity { gap: .55rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .brand-icon { width: 2.45rem; height: 2.45rem; border-radius: .82rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .brand-icon svg { width: 1.48rem; height: 1.48rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .identity h1 { font-size: 1.42rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .identity-copy > small { margin-top: .18rem; font-size: .61rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .title-row { gap: .42rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .widget .pill { min-height: 1.52rem; padding: .2rem .5rem; font-size: .54rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .mode-detail { font-size: .59rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .theme-button { width: 2.2rem; height: 2.2rem; border-radius: .75rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .speed-card { grid-area: speed; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-card { grid-area: temperature; }
|
||||||
|
:root[data-widget-layout="horizontal"] .control-card {
|
||||||
|
min-height: 8.75rem;
|
||||||
|
padding: .66rem .78rem .58rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .card-title { gap: .5rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .card-title h2,
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-title h2,
|
||||||
|
:root[data-widget-layout="horizontal"] .section-title h2 { font-size: .86rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .card-title p { margin-top: .1rem; font-size: .58rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .feature-icon { width: 1.65rem; height: 1.65rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .hero-control {
|
||||||
|
grid-template-columns: 2.75rem 1fr 2.75rem;
|
||||||
|
gap: .55rem;
|
||||||
|
margin: .46rem 0 .34rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .hero-step { width: 2.75rem; height: 2.75rem; border-radius: .78rem; font-size: 1.5rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .hero-value { font-size: 2.4rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .speed-segments button { height: .68rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .scale-labels,
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-labels { margin-top: .2rem; font-size: .52rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-range { height: 1.05rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-range::-webkit-slider-runnable-track { height: .55rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-range::-moz-range-track { height: .55rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-range::-webkit-slider-thumb { width: 1.32rem; height: 1.32rem; margin-top: -.4rem; border-width: .21rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .temperature-range::-moz-range-thumb { width: .9rem; height: .9rem; border-width: .21rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-grid { display: contents; }
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-grid > * { z-index: 1; }
|
||||||
|
:root[data-widget-layout="horizontal"] .power-card { grid-area: power; }
|
||||||
|
:root[data-widget-layout="horizontal"] .heater-card { grid-area: heater; }
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-card {
|
||||||
|
min-height: 8.75rem;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .66rem .68rem .58rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-title { gap: .4rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-title small { font-size: .52rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .switch-icon { width: 1.5rem; height: 1.5rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .glass-switch { min-height: 2.7rem; border-radius: .78rem; font-size: 1.05rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .info-card { padding: .62rem .68rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .section-icon { width: 1.5rem; height: 1.5rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .open-section { width: 1.75rem; height: 1.75rem; border-radius: .58rem; font-size: .82rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card {
|
||||||
|
grid-area: air;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.55fr) minmax(10.8rem, .82fr);
|
||||||
|
grid-template-rows: auto auto auto auto;
|
||||||
|
column-gap: .72rem;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .section-title { grid-column: 1 / -1; grid-row: 1; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .co2-summary {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
margin: .42rem .05rem .3rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .co2-meter { grid-column: 1; grid-row: 3; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .meter-labels { grid-column: 1; grid-row: 4; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .mini-metrics {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2 / 5;
|
||||||
|
align-self: stretch;
|
||||||
|
gap: .38rem;
|
||||||
|
margin-top: .4rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .mini-metric {
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .14rem;
|
||||||
|
padding: .35rem .25rem;
|
||||||
|
border-radius: .72rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .metric-icon { width: 1.25rem; height: 1.25rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .mini-metric small { font-size: .51rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .mini-metric strong { margin-top: .1rem; font-size: 1.08rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .co2-summary strong { font-size: 1.65rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card .quality-chip { padding: .25rem .48rem; font-size: .54rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .co2-meter { height: .62rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-card { grid-area: schedule; }
|
||||||
|
:root[data-widget-layout="horizontal"] .air-card,
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-card { min-height: 6.9rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-times {
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .42rem;
|
||||||
|
padding: .08rem .18rem .12rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-times small { font-size: .53rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-times strong { font-size: 1.18rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .schedule-times > i { height: 2.2rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .open-panel {
|
||||||
|
grid-area: open;
|
||||||
|
min-height: 2.45rem;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: .38rem .75rem;
|
||||||
|
border-radius: .82rem;
|
||||||
|
font-size: .68rem;
|
||||||
|
}
|
||||||
|
:root[data-widget-layout="horizontal"] .open-panel svg { width: 1.15rem; height: 1.15rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .open-panel b { font-size: .85rem; }
|
||||||
|
:root[data-widget-layout="horizontal"] .demo-note { grid-area: demo; }
|
||||||
|
|
||||||
|
button:disabled { opacity: .58; cursor: default; transform: none !important; }
|
||||||
|
|
||||||
|
@media (min-width: 560px) and (min-height: 900px) {
|
||||||
|
html { font-size: 17px; }
|
||||||
|
:root[data-widget-layout="vertical"] .widget { width: min(100%, 33rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
body { padding: .36rem; }
|
||||||
|
.widget { padding: .55rem; gap: .52rem; border-radius: 1.2rem; }
|
||||||
|
.brand-icon { width: 2.55rem; height: 2.55rem; }
|
||||||
|
.identity h1 { font-size: 1.38rem; }
|
||||||
|
.title-row { gap: .38rem; }
|
||||||
|
.widget .pill { padding-inline: .46rem; font-size: .54rem; }
|
||||||
|
.hero-control { grid-template-columns: 3.15rem 1fr 3.15rem; gap: .55rem; }
|
||||||
|
.hero-step { width: 3.15rem; height: 3.15rem; }
|
||||||
|
.hero-value { font-size: 2.65rem; }
|
||||||
|
.switch-grid { gap: .5rem; }
|
||||||
|
.switch-card { padding: .7rem; }
|
||||||
|
.switch-title { align-items: flex-start; }
|
||||||
|
.switch-title small { display: none; }
|
||||||
|
.co2-summary { grid-template-columns: auto 1fr; }
|
||||||
|
.co2-summary .quality-chip { grid-column: 1 / -1; justify-self: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero-step:hover, .theme-button:hover, .open-section:hover, .glass-switch:hover, .open-panel:hover { transform: none; }
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
const query = new URLSearchParams(window.location.search);
|
||||||
|
export const isDemo = query.get("demo") === "1";
|
||||||
|
|
||||||
|
const demoState = {
|
||||||
|
online: true,
|
||||||
|
running: true,
|
||||||
|
last_seen: new Date().toISOString(),
|
||||||
|
last_error: null,
|
||||||
|
tion: {
|
||||||
|
power: true,
|
||||||
|
heater: true,
|
||||||
|
heating: false,
|
||||||
|
sound: true,
|
||||||
|
mode: "outside",
|
||||||
|
out_temp: 21,
|
||||||
|
in_temp: 20,
|
||||||
|
target_temp: 21,
|
||||||
|
fan_speed: 3,
|
||||||
|
filter_remain: 136.7,
|
||||||
|
device_time: "22:45",
|
||||||
|
request_error_code: 0,
|
||||||
|
model: "S4",
|
||||||
|
light: true,
|
||||||
|
},
|
||||||
|
auto: {
|
||||||
|
available: true,
|
||||||
|
state: "inactive",
|
||||||
|
reason: null,
|
||||||
|
target_speed: null,
|
||||||
|
auto_speed: null,
|
||||||
|
target_heater: null,
|
||||||
|
auto_heater: null,
|
||||||
|
temperature: 25.9,
|
||||||
|
temperature_source: "qingping",
|
||||||
|
last_error: null,
|
||||||
|
config_error: null,
|
||||||
|
},
|
||||||
|
qingping: {
|
||||||
|
online: true,
|
||||||
|
temperature: 25.9,
|
||||||
|
humidity: 69.4,
|
||||||
|
co2: 780,
|
||||||
|
pm25: 0,
|
||||||
|
pm10: 0,
|
||||||
|
battery: 100,
|
||||||
|
last_error: null,
|
||||||
|
},
|
||||||
|
schedule: {
|
||||||
|
available: true,
|
||||||
|
enabled: true,
|
||||||
|
running: true,
|
||||||
|
paused: false,
|
||||||
|
current_action: "set",
|
||||||
|
current_time: "22:45",
|
||||||
|
next_time: "10:00",
|
||||||
|
next_action: "set",
|
||||||
|
override_until_time: "10:00",
|
||||||
|
auto_active: false,
|
||||||
|
auto_fallback_speed: 2,
|
||||||
|
auto_target_temp: 21,
|
||||||
|
scheduled_settings: { speed: 2, target_temp: 21, heater: true },
|
||||||
|
override_active: true,
|
||||||
|
override_until: new Date(Date.now() + 8 * 3600_000).toISOString(),
|
||||||
|
override_settings: { speed: 3 },
|
||||||
|
last_error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoScheduleConfig = {
|
||||||
|
version: 1,
|
||||||
|
enabled: true,
|
||||||
|
timezone: "local",
|
||||||
|
templates: {
|
||||||
|
workday: [
|
||||||
|
{ time: "07:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } },
|
||||||
|
{ time: "09:00", action: { type: "auto", speed: 2, target_temp: 21 } },
|
||||||
|
{ time: "22:45", action: { type: "set", speed: 1 } },
|
||||||
|
],
|
||||||
|
weekend: [
|
||||||
|
{ time: "09:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } },
|
||||||
|
{ time: "11:00", action: { type: "auto", speed: 2, target_temp: 21 } },
|
||||||
|
{ time: "23:00", action: { type: "set", speed: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
days: { mon: "workday", tue: "workday", wed: "workday", thu: "workday", fri: "workday", sat: "weekend", sun: "weekend" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const copy = value => JSON.parse(JSON.stringify(value));
|
||||||
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function mutateDemo(path) {
|
||||||
|
const tion = demoState.tion;
|
||||||
|
const schedule = demoState.schedule;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
if ((match = path.match(/^\/api\/tion\/speed\/(\d)$/))) {
|
||||||
|
tion.fan_speed = Number(match[1]);
|
||||||
|
schedule.override_active = true;
|
||||||
|
schedule.override_until_time ||= schedule.next_time;
|
||||||
|
schedule.override_settings.speed = tion.fan_speed;
|
||||||
|
} else if ((match = path.match(/^\/api\/tion\/temperature\/(\d+)$/))) {
|
||||||
|
tion.target_temp = Number(match[1]);
|
||||||
|
schedule.override_active = true;
|
||||||
|
schedule.override_until_time ||= schedule.next_time;
|
||||||
|
schedule.override_settings.target_temp = tion.target_temp;
|
||||||
|
} else if ((match = path.match(/^\/api\/tion\/(power|heater|sound|light)\/(on|off)$/))) {
|
||||||
|
tion[match[1]] = match[2] === "on";
|
||||||
|
schedule.override_active = true;
|
||||||
|
schedule.override_settings[match[1]] = tion[match[1]];
|
||||||
|
} else if ((match = path.match(/^\/api\/tion\/mode\/(outside|recirculation)$/))) {
|
||||||
|
tion.mode = match[1];
|
||||||
|
schedule.override_active = true;
|
||||||
|
schedule.override_settings.mode = match[1];
|
||||||
|
} else if (path === "/api/schedule/override/clear") {
|
||||||
|
schedule.override_active = false;
|
||||||
|
schedule.override_until = null;
|
||||||
|
schedule.override_until_time = null;
|
||||||
|
schedule.override_settings = {};
|
||||||
|
} else if (path === "/api/schedule/pause") {
|
||||||
|
schedule.paused = true;
|
||||||
|
schedule.override_active = false;
|
||||||
|
schedule.override_until_time = null;
|
||||||
|
} else if (path === "/api/schedule/resume") {
|
||||||
|
schedule.paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
demoState.last_seen = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
if (isDemo) {
|
||||||
|
await wait(options.method === "POST" ? 220 : 80);
|
||||||
|
if (path === "/api/schedule/config") {
|
||||||
|
if (options.method === "PUT") {
|
||||||
|
const replacement = JSON.parse(options.body);
|
||||||
|
Object.keys(demoScheduleConfig).forEach(key => delete demoScheduleConfig[key]);
|
||||||
|
Object.assign(demoScheduleConfig, replacement);
|
||||||
|
return { ok: true, config: copy(demoScheduleConfig) };
|
||||||
|
}
|
||||||
|
return copy(demoScheduleConfig);
|
||||||
|
}
|
||||||
|
if (options.method === "POST") mutateDemo(path);
|
||||||
|
return copy(demoState);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(path, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Ошибка ${response.status}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
if (body.detail && typeof body.detail === "object") {
|
||||||
|
message = [body.detail.message, body.detail.error].filter(Boolean).join(": ") || message;
|
||||||
|
} else {
|
||||||
|
message = body.detail || body.message || message;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
throw new Error(typeof message === "string" ? message : JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
status: () => request("/api/status"),
|
||||||
|
post: path => request(path, { method: "POST" }),
|
||||||
|
scheduleConfig: () => request("/api/schedule/config"),
|
||||||
|
saveScheduleConfig: config => request("/api/schedule/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function configureTheme(button) {
|
||||||
|
const allowed = ["auto", "light", "dark"];
|
||||||
|
const explicit = query.get("theme");
|
||||||
|
let mode = allowed.includes(explicit) ? explicit : (localStorage.getItem("tion-theme") || "auto");
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const dark = mode === "dark" || (mode === "auto" && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||||
|
document.documentElement.dataset.themeMode = mode;
|
||||||
|
if (button) {
|
||||||
|
button.title = `Тема: ${mode === "auto" ? "системная" : mode === "dark" ? "тёмная" : "светлая"}`;
|
||||||
|
button.setAttribute("aria-label", button.title);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => mode === "auto" && apply());
|
||||||
|
|
||||||
|
if (button) button.addEventListener("click", () => {
|
||||||
|
mode = allowed[(allowed.indexOf(mode) + 1) % allowed.length];
|
||||||
|
localStorage.setItem("tion-theme", mode);
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRangeProgress(input) {
|
||||||
|
const min = Number(input.min);
|
||||||
|
const max = Number(input.max);
|
||||||
|
const value = Number(input.value);
|
||||||
|
input.style.setProperty("--progress", `${((value - min) / (max - min)) * 100}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function airQuality(co2) {
|
||||||
|
if (!Number.isFinite(Number(co2))) return { label: "Нет данных", tone: "muted", pct: 0 };
|
||||||
|
const value = Number(co2);
|
||||||
|
if (value < 700) return { label: "Отличный воздух", tone: "good", pct: value / 20 };
|
||||||
|
if (value < 900) return { label: "Хороший воздух", tone: "good", pct: value / 20 };
|
||||||
|
if (value < 1300) return { label: "Повышенный CO₂", tone: "warn", pct: value / 20 };
|
||||||
|
if (value < 1600) return { label: "Душно", tone: "warn", pct: value / 20 };
|
||||||
|
return { label: "Нужна вентиляция", tone: "bad", pct: Math.min(100, value / 20) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const AIR_METER_STOPS = [
|
||||||
|
[400, [35, 143, 83]],
|
||||||
|
[700, [46, 166, 99]],
|
||||||
|
[820, [82, 181, 111]],
|
||||||
|
[900, [190, 153, 55]],
|
||||||
|
[1200, [214, 128, 45]],
|
||||||
|
[1500, [215, 83, 55]],
|
||||||
|
[2000, [188, 48, 72]],
|
||||||
|
];
|
||||||
|
|
||||||
|
const interpolateChannel = (start, end, amount) => Math.round(start + (end - start) * amount);
|
||||||
|
|
||||||
|
export function airMeterPalette(co2) {
|
||||||
|
const value = Number(co2);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return {
|
||||||
|
color: "rgb(105, 137, 158)",
|
||||||
|
glow: "rgba(83, 123, 150, .2)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounded = Math.max(AIR_METER_STOPS[0][0], Math.min(AIR_METER_STOPS.at(-1)[0], value));
|
||||||
|
let lower = AIR_METER_STOPS[0];
|
||||||
|
let upper = AIR_METER_STOPS.at(-1);
|
||||||
|
|
||||||
|
for (let index = 1; index < AIR_METER_STOPS.length; index += 1) {
|
||||||
|
if (bounded <= AIR_METER_STOPS[index][0]) {
|
||||||
|
lower = AIR_METER_STOPS[index - 1];
|
||||||
|
upper = AIR_METER_STOPS[index];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = upper[0] === lower[0] ? 0 : (bounded - lower[0]) / (upper[0] - lower[0]);
|
||||||
|
const [red, green, blue] = lower[1].map((channel, index) => interpolateChannel(channel, upper[1][index], amount));
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: `rgb(${red}, ${green}, ${blue})`,
|
||||||
|
glow: `rgba(${red}, ${green}, ${blue}, .26)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modeInfo(status) {
|
||||||
|
const schedule = status?.schedule || {};
|
||||||
|
if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" };
|
||||||
|
if (schedule.paused) return { label: "Ручной режим", detail: "Расписание на паузе", tone: "warn" };
|
||||||
|
if (schedule.override_active) return {
|
||||||
|
label: "Ручное управление",
|
||||||
|
detail: schedule.override_until_time ? `До ${schedule.override_until_time}` : "До следующей точки",
|
||||||
|
tone: "warn",
|
||||||
|
};
|
||||||
|
if (schedule.auto_active) return { label: "AUTO", detail: "По качеству воздуха", tone: "info" };
|
||||||
|
return { label: "Расписание", detail: "Активно", tone: "info" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const numberOrDash = (value, digits = 0) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number.toFixed(digits) : "—";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showToast(message, error = false) {
|
||||||
|
let region = document.querySelector(".toast-region");
|
||||||
|
if (!region) {
|
||||||
|
region = document.createElement("div");
|
||||||
|
region.className = "toast-region";
|
||||||
|
document.body.append(region);
|
||||||
|
}
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = `toast${error ? " error" : ""}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
region.append(toast);
|
||||||
|
setTimeout(() => toast.remove(), 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panelUrl(themeMode = "auto", hash = "") {
|
||||||
|
const url = new URL("/ui/panel", window.location.origin);
|
||||||
|
url.searchParams.set("theme", themeMode);
|
||||||
|
if (isDemo) url.searchParams.set("demo", "1");
|
||||||
|
url.hash = hash;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
+476
@@ -0,0 +1,476 @@
|
|||||||
|
import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js";
|
||||||
|
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
configureTheme($("#theme-button"));
|
||||||
|
let state = null;
|
||||||
|
let busy = false;
|
||||||
|
let scheduleDraft = null;
|
||||||
|
let activeTemplate = null;
|
||||||
|
|
||||||
|
const weekdays = [
|
||||||
|
["mon", "Понедельник"],
|
||||||
|
["tue", "Вторник"],
|
||||||
|
["wed", "Среда"],
|
||||||
|
["thu", "Четверг"],
|
||||||
|
["fri", "Пятница"],
|
||||||
|
["sat", "Суббота"],
|
||||||
|
["sun", "Воскресенье"],
|
||||||
|
];
|
||||||
|
const templateNames = { workday: "Будний", weekend: "Выходной" };
|
||||||
|
const clone = value => JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
function templateLabel(name) {
|
||||||
|
return templateNames[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDemo) $("#demo-note").hidden = false;
|
||||||
|
|
||||||
|
function text(selector, value) { $(selector).textContent = value; }
|
||||||
|
function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; }
|
||||||
|
|
||||||
|
function toggle(selector, active, label = "") {
|
||||||
|
const button = $(selector);
|
||||||
|
button.setAttribute("aria-pressed", String(Boolean(active)));
|
||||||
|
button.textContent = `${label}${label ? " " : ""}${active ? "ON" : "OFF"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(selector, value) {
|
||||||
|
const input = $(selector);
|
||||||
|
if (Number.isFinite(Number(value))) input.value = value;
|
||||||
|
setRangeProgress(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSpeedVisual(value) {
|
||||||
|
const speed = Math.max(1, Math.min(6, Number(value) || 1));
|
||||||
|
text("#speed-value", speed);
|
||||||
|
setRange("#speed-range", speed);
|
||||||
|
document.querySelectorAll(".speed-segments button").forEach((segment, index) => {
|
||||||
|
segment.classList.toggle("active", index < speed);
|
||||||
|
segment.setAttribute("aria-pressed", String(index + 1 === speed));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemperatureVisual(value) {
|
||||||
|
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
|
||||||
|
text("#temperature-value", temperature);
|
||||||
|
setRange("#temperature-range", temperature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(value) {
|
||||||
|
return ({ set: "Настройка режима", auto: "Автоматический режим", off: "Выключение" })[value] || "Точка расписания";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAir(sensor, tion) {
|
||||||
|
const quality = airQuality(sensor.co2);
|
||||||
|
const airTone = ["good", "warn", "bad"].includes(quality.tone) ? quality.tone : "muted";
|
||||||
|
const airPalette = airMeterPalette(sensor.co2);
|
||||||
|
const airCard = $(".co2-card");
|
||||||
|
text("#co2", numberOrDash(sensor.co2));
|
||||||
|
text("#air-label", quality.label);
|
||||||
|
text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика");
|
||||||
|
$("#air-label").className = `quality-chip ${airTone}`;
|
||||||
|
airCard.dataset.airTone = airTone;
|
||||||
|
airCard.style.setProperty("--air-color", airPalette.color);
|
||||||
|
airCard.style.setProperty("--air-glow", airPalette.glow);
|
||||||
|
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
|
||||||
|
setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad");
|
||||||
|
text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
|
||||||
|
text("#humidity", numberOrDash(sensor.humidity));
|
||||||
|
text("#pm25", numberOrDash(sensor.pm25));
|
||||||
|
text("#pm10", numberOrDash(sensor.pm10));
|
||||||
|
const pm25 = Number(sensor.pm25);
|
||||||
|
const pm10 = Number(sensor.pm10);
|
||||||
|
text("#pm25-quality", Number.isFinite(pm25) ? (pm25 <= 15 ? "Отлично" : pm25 <= 35 ? "Норма" : "Повышено") : "—");
|
||||||
|
text("#pm10-quality", Number.isFinite(pm10) ? (pm10 <= 30 ? "Отлично" : pm10 <= 60 ? "Норма" : "Повышено") : "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedule(status) {
|
||||||
|
const schedule = status.schedule || {};
|
||||||
|
const mode = modeInfo(status);
|
||||||
|
text("#current-time", schedule.current_time || status.tion?.device_time || "—");
|
||||||
|
text("#next-time", schedule.next_time || "—");
|
||||||
|
text("#current-action", actionLabel(schedule.current_action));
|
||||||
|
text("#next-action", actionLabel(schedule.next_action));
|
||||||
|
text("#mode-title", mode.label);
|
||||||
|
text("#mode-detail", mode.detail);
|
||||||
|
setPill("#schedule-state", schedule.paused ? "Пауза" : schedule.enabled ? "Активно" : "Выкл", schedule.paused ? "warn" : schedule.enabled ? "info" : "muted");
|
||||||
|
const headerMode = schedule.override_active && schedule.override_until_time
|
||||||
|
? `Ручной до ${schedule.override_until_time}`
|
||||||
|
: mode.label;
|
||||||
|
setPill("#mode-pill", headerMode, mode.tone);
|
||||||
|
$("#mode-banner").className = `mode-banner ${mode.tone === "info" ? "info" : mode.tone === "muted" ? "muted" : "warn"}`;
|
||||||
|
$("#auto-button").disabled = !schedule.available || (!schedule.override_active && !schedule.paused);
|
||||||
|
$("#pause-button strong").textContent = schedule.paused ? "Возобновить расписание" : "Пауза расписания";
|
||||||
|
$("#pause-button small").textContent = schedule.paused ? "Продолжить по расписанию" : "Временно приостановить";
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(status) {
|
||||||
|
state = status;
|
||||||
|
const tion = status.tion || {};
|
||||||
|
const sensor = status.qingping || {};
|
||||||
|
setPill("#connection", status.online ? "Online" : "Offline", status.online ? "good" : "bad");
|
||||||
|
updateSpeedVisual(tion.fan_speed);
|
||||||
|
updateTemperatureVisual(tion.target_temp);
|
||||||
|
text("#speed-note", status.auto?.state === "active" && status.auto?.target_speed ? `AUTO выбрал скорость ${status.auto.target_speed}` : `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
|
||||||
|
text("#temperature-note", tion.heating ? "Сейчас нагревает входящий воздух" : "Желаемая температура в помещении");
|
||||||
|
toggle("#power-toggle", tion.power);
|
||||||
|
toggle("#heater-toggle", tion.heater);
|
||||||
|
toggle("#sound-toggle", tion.sound, "Звук");
|
||||||
|
toggle("#light-toggle", tion.light, "Свет");
|
||||||
|
text("#power-caption", tion.power ? "Устройство включено" : "Устройство выключено");
|
||||||
|
text("#heater-caption", tion.heating ? "Сейчас нагревает воздух" : "Поддержание температуры");
|
||||||
|
$("#mode-outside").classList.toggle("active", tion.mode === "outside");
|
||||||
|
$("#mode-recirculation").classList.toggle("active", tion.mode === "recirculation");
|
||||||
|
text("#mode-name", tion.mode === "recirculation" ? "Рециркуляция" : "Приточная вентиляция");
|
||||||
|
text("#outside-temp", numberOrDash(tion.out_temp));
|
||||||
|
text("#filter-remain", numberOrDash(tion.filter_remain));
|
||||||
|
const filterPercent = Math.max(0, Math.min(100, Number(tion.filter_remain || 0) / 2));
|
||||||
|
$("#filter-progress").style.width = `${filterPercent}%`;
|
||||||
|
text("#filter-percent", `${Math.round(filterPercent)}%`);
|
||||||
|
text("#model", tion.model || "—");
|
||||||
|
text("#battery", numberOrDash(sensor.battery));
|
||||||
|
renderAir(sensor, tion);
|
||||||
|
renderSchedule(status);
|
||||||
|
text("#updated-at", `Обновлено ${new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(silent = false) {
|
||||||
|
try { render(await api.status()); }
|
||||||
|
catch (error) {
|
||||||
|
setPill("#connection", "Ошибка связи", "bad");
|
||||||
|
if (!silent) showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(path, source) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
source?.setAttribute("disabled", "");
|
||||||
|
source?.setAttribute("aria-busy", "true");
|
||||||
|
try { await api.post(path); await refresh(true); }
|
||||||
|
catch (error) { showToast(error.message, true); await refresh(true); }
|
||||||
|
finally { busy = false; source?.removeAttribute("disabled"); source?.removeAttribute("aria-busy"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepped(kind, delta, source) {
|
||||||
|
if (busy) return;
|
||||||
|
const isSpeed = kind === "speed";
|
||||||
|
const current = Number(isSpeed ? state?.tion?.fan_speed : state?.tion?.target_temp);
|
||||||
|
const min = isSpeed ? 1 : 5;
|
||||||
|
const max = isSpeed ? 6 : 30;
|
||||||
|
const value = Math.min(max, Math.max(min, current + delta));
|
||||||
|
if (Number.isFinite(value) && value !== current) {
|
||||||
|
if (isSpeed) {
|
||||||
|
state.tion.fan_speed = value;
|
||||||
|
updateSpeedVisual(value);
|
||||||
|
} else {
|
||||||
|
state.tion.target_temp = value;
|
||||||
|
updateTemperatureVisual(value);
|
||||||
|
}
|
||||||
|
act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionList(values, current, emptyLabel = "Не менять") {
|
||||||
|
const items = emptyLabel === null ? [] : [["", emptyLabel]];
|
||||||
|
items.push(...values);
|
||||||
|
return items.map(([value, label]) => `<option value="${value}"${String(current ?? "") === String(value) ? " selected" : ""}>${label}</option>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function booleanSetting(value) {
|
||||||
|
return value === true ? "on" : value === false ? "off" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointMarkup(point, index) {
|
||||||
|
const action = point?.action || {};
|
||||||
|
const type = action.type === "auto" ? "auto" : "set";
|
||||||
|
const speedOptions = Array.from({ length: 6 }, (_, item) => [String(item + 1), String(item + 1)]);
|
||||||
|
const booleanOptions = [["on", "Включить"], ["off", "Выключить"]];
|
||||||
|
return `
|
||||||
|
<article class="point-editor ${type}" data-index="${index}">
|
||||||
|
<div class="point-header">
|
||||||
|
<span class="point-number">${index + 1}</span>
|
||||||
|
<label>Время<input data-field="time" type="time" value="${point?.time || ""}" required></label>
|
||||||
|
<label class="point-action">Действие<select data-field="type"><option value="set"${type === "set" ? " selected" : ""}>Задать параметры</option><option value="auto"${type === "auto" ? " selected" : ""}>AUTO по воздуху</option></select></label>
|
||||||
|
<button class="remove-point" data-remove-point="${index}" type="button" aria-label="Удалить точку">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="point-settings">
|
||||||
|
<label class="point-setting">Скорость<select data-setting="speed">${optionList(speedOptions, action.speed, "Не менять")}</select></label>
|
||||||
|
<label class="point-setting">Температура<input data-setting="target_temp" type="number" min="5" max="30" step="1" value="${action.target_temp ?? ""}" placeholder="—"></label>
|
||||||
|
<label class="point-setting" data-set-only>Питание<select data-setting="power">${optionList(booleanOptions, booleanSetting(action.power))}</select></label>
|
||||||
|
<label class="point-setting" data-set-only>Обогрев<select data-setting="heater">${optionList(booleanOptions, booleanSetting(action.heater))}</select></label>
|
||||||
|
<label class="point-setting" data-set-only>Воздух<select data-setting="mode">${optionList([["outside", "С улицы"], ["recirculation", "Рециркуляция"]], action.mode)}</select></label>
|
||||||
|
<label class="point-setting" data-set-only>Звук<select data-setting="sound">${optionList(booleanOptions, booleanSetting(action.sound))}</select></label>
|
||||||
|
<label class="point-setting" data-set-only>Подсветка<select data-setting="light">${optionList(booleanOptions, booleanSetting(action.light))}</select></label>
|
||||||
|
</div>
|
||||||
|
</article>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRenderedPoints(strict = false) {
|
||||||
|
const points = [...document.querySelectorAll(".point-editor")].map(card => {
|
||||||
|
const time = card.querySelector('[data-field="time"]').value;
|
||||||
|
const type = card.querySelector('[data-field="type"]').value;
|
||||||
|
const action = { type };
|
||||||
|
|
||||||
|
card.querySelectorAll("[data-setting]").forEach(input => {
|
||||||
|
const field = input.dataset.setting;
|
||||||
|
const raw = input.value;
|
||||||
|
if (raw === "" || (type === "auto" && !["speed", "target_temp"].includes(field))) return;
|
||||||
|
if (["speed", "target_temp"].includes(field)) action[field] = Number(raw);
|
||||||
|
else if (["power", "heater", "sound", "light"].includes(field)) action[field] = raw === "on";
|
||||||
|
else action[field] = raw;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { time, action };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (strict) validateTemplate(activeTemplate, points);
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTemplate(name, points) {
|
||||||
|
const label = templateLabel(name);
|
||||||
|
if (!Array.isArray(points) || !points.length) throw new Error(`Шаблон «${label}» не может быть пустым`);
|
||||||
|
const times = new Set();
|
||||||
|
points.forEach((point, index) => {
|
||||||
|
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(point.time || "")) throw new Error(`Проверьте время в точке ${index + 1} шаблона «${label}»`);
|
||||||
|
if (times.has(point.time)) throw new Error(`В шаблоне «${label}» время ${point.time} указано дважды`);
|
||||||
|
times.add(point.time);
|
||||||
|
const action = point.action || {};
|
||||||
|
if (!["set", "auto"].includes(action.type)) throw new Error(`Неизвестное действие в шаблоне «${label}»`);
|
||||||
|
if (action.speed !== undefined && (!Number.isInteger(action.speed) || action.speed < 1 || action.speed > 6)) throw new Error(`Скорость в шаблоне «${label}» должна быть от 1 до 6`);
|
||||||
|
if (action.target_temp !== undefined && (!Number.isInteger(action.target_temp) || action.target_temp < 5 || action.target_temp > 30)) throw new Error(`Температура в шаблоне «${label}» должна быть от 5 до 30°`);
|
||||||
|
if (action.type === "auto" && action.speed === undefined) throw new Error(`Для AUTO в шаблоне «${label}» укажите базовую скорость`);
|
||||||
|
if (action.type === "set" && Object.keys(action).length === 1) throw new Error(`В точке ${point.time} шаблона «${label}» не выбран ни один параметр`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScheduleDraft() {
|
||||||
|
const names = Object.keys(scheduleDraft?.templates || {});
|
||||||
|
if (!names.length) throw new Error("В расписании должен быть хотя бы один шаблон");
|
||||||
|
names.forEach(name => validateTemplate(name, scheduleDraft.templates[name]));
|
||||||
|
weekdays.forEach(([day, label]) => {
|
||||||
|
if (!names.includes(scheduleDraft.days?.[day])) throw new Error(`Для дня «${label}» не выбран шаблон`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitActiveTemplate(strict = false) {
|
||||||
|
if (scheduleDraft && activeTemplate) scheduleDraft.templates[activeTemplate] = readRenderedPoints(strict);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplateUsage() {
|
||||||
|
const usedBy = weekdays.filter(([day]) => scheduleDraft.days?.[day] === activeTemplate).map(([, label]) => label.toLowerCase());
|
||||||
|
text("#template-usage", usedBy.length ? `Используют: ${usedBy.join(", ")}` : "Пока не назначен ни одному дню");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplateSelector() {
|
||||||
|
const select = $("#template-select");
|
||||||
|
select.replaceChildren(...Object.keys(scheduleDraft.templates).map(name => new Option(templateLabel(name), name, false, name === activeTemplate)));
|
||||||
|
$("#delete-template").disabled = Object.keys(scheduleDraft.templates).length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDayAssignments() {
|
||||||
|
const host = $("#day-assignments");
|
||||||
|
const templateNames = Object.keys(scheduleDraft.templates);
|
||||||
|
host.replaceChildren(...weekdays.map(([day, label]) => {
|
||||||
|
const wrapper = document.createElement("label");
|
||||||
|
wrapper.className = "day-assignment";
|
||||||
|
wrapper.textContent = label;
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.dataset.day = day;
|
||||||
|
select.replaceChildren(...templateNames.map(name => new Option(templateLabel(name), name, false, scheduleDraft.days?.[day] === name)));
|
||||||
|
wrapper.append(select);
|
||||||
|
return wrapper;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSchedulePoints() {
|
||||||
|
const points = scheduleDraft.templates[activeTemplate] || [];
|
||||||
|
$("#schedule-points").innerHTML = points.length
|
||||||
|
? points.map(pointMarkup).join("")
|
||||||
|
: '<div class="empty-points">В этом шаблоне пока нет точек</div>';
|
||||||
|
renderTemplateUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditorStatus(message = "", error = false) {
|
||||||
|
const status = $("#schedule-editor-status");
|
||||||
|
status.textContent = message;
|
||||||
|
status.classList.toggle("error", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openScheduleEditor() {
|
||||||
|
const source = $("#schedule-edit-button");
|
||||||
|
source.disabled = true;
|
||||||
|
source.setAttribute("aria-busy", "true");
|
||||||
|
try {
|
||||||
|
scheduleDraft = clone(await api.scheduleConfig());
|
||||||
|
const names = Object.keys(scheduleDraft.templates || {});
|
||||||
|
if (!names.length) throw new Error("В расписании нет шаблонов");
|
||||||
|
const dayKeys = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
||||||
|
activeTemplate = scheduleDraft.days?.[dayKeys[new Date().getDay()]] || names[0];
|
||||||
|
$("#schedule-enabled").checked = scheduleDraft.enabled !== false;
|
||||||
|
renderTemplateSelector();
|
||||||
|
renderDayAssignments();
|
||||||
|
renderSchedulePoints();
|
||||||
|
setEditorStatus();
|
||||||
|
$("#schedule-dialog").showModal();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Не удалось открыть редактор: ${error.message}`, true);
|
||||||
|
} finally {
|
||||||
|
source.disabled = false;
|
||||||
|
source.removeAttribute("aria-busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScheduleEditor() {
|
||||||
|
$("#schedule-dialog").close();
|
||||||
|
scheduleDraft = null;
|
||||||
|
activeTemplate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveScheduleEditor(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!scheduleDraft) return;
|
||||||
|
const saveButton = $("#schedule-save-button");
|
||||||
|
try {
|
||||||
|
commitActiveTemplate(true);
|
||||||
|
scheduleDraft.enabled = $("#schedule-enabled").checked;
|
||||||
|
validateScheduleDraft();
|
||||||
|
Object.values(scheduleDraft.templates).forEach(points => points.sort((left, right) => left.time.localeCompare(right.time)));
|
||||||
|
setEditorStatus("Сохраняем…");
|
||||||
|
saveButton.disabled = true;
|
||||||
|
saveButton.setAttribute("aria-busy", "true");
|
||||||
|
const result = await api.saveScheduleConfig(scheduleDraft);
|
||||||
|
$("#schedule-dialog").close();
|
||||||
|
scheduleDraft = null;
|
||||||
|
activeTemplate = null;
|
||||||
|
showToast(result.restart_required ? "Расписание сохранено. Перезапустите сервис для применения" : "Расписание сохранено и применено");
|
||||||
|
await refresh(true);
|
||||||
|
} catch (error) {
|
||||||
|
setEditorStatus(error.message, true);
|
||||||
|
} finally {
|
||||||
|
saveButton.disabled = false;
|
||||||
|
saveButton.removeAttribute("aria-busy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => stepped(button.dataset.step, Number(button.dataset.delta), button)));
|
||||||
|
|
||||||
|
document.querySelectorAll(".speed-segments button").forEach(button => button.addEventListener("click", () => {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const value = Number(button.dataset.speed);
|
||||||
|
state.tion.fan_speed = value;
|
||||||
|
updateSpeedVisual(value);
|
||||||
|
act(`/api/tion/speed/${value}`, button);
|
||||||
|
}));
|
||||||
|
|
||||||
|
$("#speed-range").addEventListener("input", event => updateSpeedVisual(event.target.value));
|
||||||
|
$("#speed-range").addEventListener("change", event => act(`/api/tion/speed/${event.target.value}`, event.target));
|
||||||
|
$("#temperature-range").addEventListener("input", event => { setRangeProgress(event.target); text("#temperature-value", event.target.value); });
|
||||||
|
$("#temperature-range").addEventListener("change", event => act(`/api/tion/temperature/${event.target.value}`, event.target));
|
||||||
|
|
||||||
|
$("#power-toggle").addEventListener("click", event => act(`/api/tion/power/${state?.tion?.power ? "off" : "on"}`, event.currentTarget));
|
||||||
|
$("#heater-toggle").addEventListener("click", event => act(`/api/tion/heater/${state?.tion?.heater ? "off" : "on"}`, event.currentTarget));
|
||||||
|
$("#sound-toggle").addEventListener("click", event => act(`/api/tion/sound/${state?.tion?.sound ? "off" : "on"}`, event.currentTarget));
|
||||||
|
$("#light-toggle").addEventListener("click", event => act(`/api/tion/light/${state?.tion?.light ? "off" : "on"}`, event.currentTarget));
|
||||||
|
$("#mode-outside").addEventListener("click", event => act("/api/tion/mode/outside", event.currentTarget));
|
||||||
|
$("#mode-recirculation").addEventListener("click", event => act("/api/tion/mode/recirculation", event.currentTarget));
|
||||||
|
$("#auto-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/override/clear", event.currentTarget));
|
||||||
|
$("#pause-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/pause", event.currentTarget));
|
||||||
|
$("#refresh-button").addEventListener("click", event => { event.currentTarget.animate([{ transform: "rotate(0)" }, { transform: "rotate(360deg)" }], { duration: 450 }); refresh(); });
|
||||||
|
|
||||||
|
$("#schedule-edit-button").addEventListener("click", openScheduleEditor);
|
||||||
|
$("#schedule-close-button").addEventListener("click", closeScheduleEditor);
|
||||||
|
$("#schedule-cancel-button").addEventListener("click", closeScheduleEditor);
|
||||||
|
$("#schedule-form").addEventListener("submit", saveScheduleEditor);
|
||||||
|
$("#schedule-dialog").addEventListener("close", () => { scheduleDraft = null; activeTemplate = null; });
|
||||||
|
|
||||||
|
$("#template-select").addEventListener("change", event => {
|
||||||
|
if (!scheduleDraft) return;
|
||||||
|
commitActiveTemplate(false);
|
||||||
|
activeTemplate = event.target.value;
|
||||||
|
renderSchedulePoints();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#duplicate-template").addEventListener("click", () => {
|
||||||
|
if (!scheduleDraft || !activeTemplate) return;
|
||||||
|
commitActiveTemplate(false);
|
||||||
|
const base = `${activeTemplate}-copy`;
|
||||||
|
let name = base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (scheduleDraft.templates[name]) name = `${base}-${suffix++}`;
|
||||||
|
scheduleDraft.templates[name] = clone(scheduleDraft.templates[activeTemplate]);
|
||||||
|
activeTemplate = name;
|
||||||
|
renderTemplateSelector();
|
||||||
|
renderDayAssignments();
|
||||||
|
renderSchedulePoints();
|
||||||
|
setEditorStatus(`Создан шаблон «${templateLabel(name)}». Назначьте его нужным дням`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#delete-template").addEventListener("click", () => {
|
||||||
|
if (!scheduleDraft || Object.keys(scheduleDraft.templates).length <= 1) return;
|
||||||
|
if (!window.confirm(`Удалить шаблон «${templateLabel(activeTemplate)}»?`)) return;
|
||||||
|
const removed = activeTemplate;
|
||||||
|
delete scheduleDraft.templates[removed];
|
||||||
|
activeTemplate = Object.keys(scheduleDraft.templates)[0];
|
||||||
|
weekdays.forEach(([day]) => {
|
||||||
|
if (scheduleDraft.days[day] === removed) scheduleDraft.days[day] = activeTemplate;
|
||||||
|
});
|
||||||
|
renderTemplateSelector();
|
||||||
|
renderDayAssignments();
|
||||||
|
renderSchedulePoints();
|
||||||
|
setEditorStatus(`Шаблон «${templateLabel(removed)}» удалён`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#day-assignments").addEventListener("change", event => {
|
||||||
|
const select = event.target.closest("[data-day]");
|
||||||
|
if (!select || !scheduleDraft) return;
|
||||||
|
scheduleDraft.days[select.dataset.day] = select.value;
|
||||||
|
renderTemplateUsage();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#schedule-points").addEventListener("change", event => {
|
||||||
|
if (event.target.dataset.field !== "type") return;
|
||||||
|
const card = event.target.closest(".point-editor");
|
||||||
|
const isAuto = event.target.value === "auto";
|
||||||
|
card.classList.toggle("auto", isAuto);
|
||||||
|
card.classList.toggle("set", !isAuto);
|
||||||
|
const speed = card.querySelector('[data-setting="speed"]');
|
||||||
|
if (isAuto && !speed.value) speed.value = "2";
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#schedule-points").addEventListener("click", event => {
|
||||||
|
const button = event.target.closest("[data-remove-point]");
|
||||||
|
if (!button || !scheduleDraft) return;
|
||||||
|
commitActiveTemplate(false);
|
||||||
|
scheduleDraft.templates[activeTemplate].splice(Number(button.dataset.removePoint), 1);
|
||||||
|
renderSchedulePoints();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#add-schedule-point").addEventListener("click", () => {
|
||||||
|
if (!scheduleDraft) return;
|
||||||
|
commitActiveTemplate(false);
|
||||||
|
const points = scheduleDraft.templates[activeTemplate];
|
||||||
|
const used = new Set(points.map(point => point.time));
|
||||||
|
const existingTimes = points.map(point => {
|
||||||
|
const [hours, minute] = String(point.time || "").split(":").map(Number);
|
||||||
|
return hours * 60 + minute;
|
||||||
|
}).filter(Number.isFinite);
|
||||||
|
let minutes = existingTimes.length ? (Math.max(...existingTimes) + 60) % 1440 : 8 * 60;
|
||||||
|
for (let attempt = 0; attempt < 48; attempt += 1) {
|
||||||
|
const candidate = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
||||||
|
if (!used.has(candidate)) {
|
||||||
|
points.push({ time: candidate, action: { type: "set", speed: 2 } });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
minutes = (minutes + 30) % 1440;
|
||||||
|
}
|
||||||
|
points.sort((left, right) => left.time.localeCompare(right.time));
|
||||||
|
renderSchedulePoints();
|
||||||
|
$("#schedule-points .point-editor:last-of-type")?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||||
|
});
|
||||||
|
|
||||||
|
setRangeProgress($("#speed-range"));
|
||||||
|
setRangeProgress($("#temperature-range"));
|
||||||
|
refresh();
|
||||||
|
setInterval(() => !document.hidden && !busy && refresh(true), 3000);
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js";
|
||||||
|
|
||||||
|
const requestedLayout = new URLSearchParams(window.location.search).get("layout");
|
||||||
|
const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null;
|
||||||
|
const wideLayout = window.matchMedia("(min-width: 800px)");
|
||||||
|
|
||||||
|
function applyLayout() {
|
||||||
|
document.documentElement.dataset.widgetLayout = layoutMode || (wideLayout.matches ? "horizontal" : "vertical");
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLayout();
|
||||||
|
wideLayout.addEventListener("change", () => !layoutMode && applyLayout());
|
||||||
|
|
||||||
|
const $ = selector => document.querySelector(selector);
|
||||||
|
const themeMode = configureTheme($("#theme-button"));
|
||||||
|
let state = null;
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
if (isDemo) $("#demo-note").hidden = false;
|
||||||
|
|
||||||
|
function setText(selector, value) { $(selector).textContent = value; }
|
||||||
|
function setPill(element, label, tone) { element.textContent = label; element.className = `pill ${tone}`; }
|
||||||
|
function toneClass(tone) { return ["good", "warn", "bad"].includes(tone) ? tone : "muted"; }
|
||||||
|
|
||||||
|
function updateSpeedVisual(value) {
|
||||||
|
const speed = Math.max(1, Math.min(6, Number(value) || 1));
|
||||||
|
setText("#speed", speed);
|
||||||
|
$("#speed").classList.remove("skeleton");
|
||||||
|
document.querySelectorAll("[data-speed]").forEach(button => {
|
||||||
|
const segment = Number(button.dataset.speed);
|
||||||
|
button.classList.toggle("active", segment <= speed);
|
||||||
|
button.setAttribute("aria-pressed", String(segment === speed));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemperatureVisual(value) {
|
||||||
|
const temperature = Math.max(5, Math.min(30, Number(value) || 5));
|
||||||
|
setText("#target-temp", temperature);
|
||||||
|
$("#target-temp").classList.remove("skeleton");
|
||||||
|
$("#temperature-range").value = temperature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawToggle(selector, active) {
|
||||||
|
const button = $(selector);
|
||||||
|
button.setAttribute("aria-pressed", String(Boolean(active)));
|
||||||
|
button.textContent = active ? "ON" : "OFF";
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(status) {
|
||||||
|
state = status;
|
||||||
|
const tion = status.tion || {};
|
||||||
|
const sensor = status.qingping || {};
|
||||||
|
const schedule = status.schedule || {};
|
||||||
|
const mode = modeInfo(status);
|
||||||
|
const quality = airQuality(sensor.co2);
|
||||||
|
const headerMode = schedule.override_active && schedule.override_until_time
|
||||||
|
? `Ручной до ${schedule.override_until_time}`
|
||||||
|
: mode.label;
|
||||||
|
|
||||||
|
setPill($("#connection"), status.online ? "Online" : "Offline", status.online ? "good" : "bad");
|
||||||
|
setPill($("#mode-pill"), headerMode, mode.tone);
|
||||||
|
setText("#mode-detail", mode.detail);
|
||||||
|
|
||||||
|
updateSpeedVisual(tion.fan_speed);
|
||||||
|
setText("#speed-note", status.auto?.state === "active" && status.auto?.target_speed
|
||||||
|
? `AUTO выбрал скорость ${status.auto.target_speed}`
|
||||||
|
: `Ступень ${numberOrDash(tion.fan_speed)} из 6`);
|
||||||
|
updateTemperatureVisual(tion.target_temp);
|
||||||
|
setText("#heating-note", tion.heating ? "Сейчас нагревает входящий воздух" : tion.heater ? "Обогрев разрешён" : "Желаемая температура воздуха");
|
||||||
|
|
||||||
|
drawToggle("#power-toggle", tion.power);
|
||||||
|
drawToggle("#heater-toggle", tion.heater);
|
||||||
|
setText("#power-detail", tion.power ? "Устройство включено" : "Устройство выключено");
|
||||||
|
setText("#heater-detail", tion.heating ? "Сейчас нагревает" : tion.heater ? "Автоматически" : "Выключен");
|
||||||
|
|
||||||
|
setText("#co2", numberOrDash(sensor.co2));
|
||||||
|
setText("#air-label", quality.label);
|
||||||
|
const airTone = toneClass(quality.tone);
|
||||||
|
const airPalette = airMeterPalette(sensor.co2);
|
||||||
|
const airCard = $(".air-card");
|
||||||
|
$("#air-label").className = `quality-chip ${airTone}`;
|
||||||
|
airCard.dataset.airTone = airTone;
|
||||||
|
airCard.style.setProperty("--air-color", airPalette.color);
|
||||||
|
airCard.style.setProperty("--air-glow", airPalette.glow);
|
||||||
|
$("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
|
||||||
|
setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
|
||||||
|
setText("#humidity", numberOrDash(sensor.humidity));
|
||||||
|
setText("#current-time", schedule.current_time || tion.device_time || "—");
|
||||||
|
setText("#next-time", schedule.next_time || "—");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(silent = false) {
|
||||||
|
try { render(await api.status()); }
|
||||||
|
catch (error) {
|
||||||
|
setPill($("#connection"), "Ошибка", "bad");
|
||||||
|
if (!silent) showToast(error.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function act(path, source) {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
source?.setAttribute("disabled", "");
|
||||||
|
try { await api.post(path); await refresh(true); }
|
||||||
|
catch (error) { showToast(error.message, true); await refresh(true); }
|
||||||
|
finally { busy = false; source?.removeAttribute("disabled"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepped(kind, delta, source) {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const isSpeed = kind === "speed";
|
||||||
|
const current = Number(isSpeed ? state.tion.fan_speed : state.tion.target_temp);
|
||||||
|
const min = isSpeed ? 1 : 5;
|
||||||
|
const max = isSpeed ? 6 : 30;
|
||||||
|
const value = Math.min(max, Math.max(min, current + delta));
|
||||||
|
if (!Number.isFinite(value) || value === current) return;
|
||||||
|
|
||||||
|
if (isSpeed) {
|
||||||
|
state.tion.fan_speed = value;
|
||||||
|
updateSpeedVisual(value);
|
||||||
|
} else {
|
||||||
|
state.tion.target_temp = value;
|
||||||
|
updateTemperatureVisual(value);
|
||||||
|
}
|
||||||
|
act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => {
|
||||||
|
stepped(button.dataset.step, Number(button.dataset.delta), button);
|
||||||
|
}));
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-speed]").forEach(button => button.addEventListener("click", () => {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const value = Number(button.dataset.speed);
|
||||||
|
state.tion.fan_speed = value;
|
||||||
|
updateSpeedVisual(value);
|
||||||
|
act(`/api/tion/speed/${value}`, button);
|
||||||
|
}));
|
||||||
|
|
||||||
|
$("#temperature-range").addEventListener("input", event => setText("#target-temp", event.target.value));
|
||||||
|
$("#temperature-range").addEventListener("change", event => {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
state.tion.target_temp = value;
|
||||||
|
updateTemperatureVisual(value);
|
||||||
|
act(`/api/tion/temperature/${value}`, event.target);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#power-toggle").addEventListener("click", event => {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const value = !state.tion.power;
|
||||||
|
state.tion.power = value;
|
||||||
|
drawToggle("#power-toggle", value);
|
||||||
|
setText("#power-detail", value ? "Устройство включено" : "Устройство выключено");
|
||||||
|
act(`/api/tion/power/${value ? "on" : "off"}`, event.currentTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#heater-toggle").addEventListener("click", event => {
|
||||||
|
if (busy || !state?.tion) return;
|
||||||
|
const value = !state.tion.heater;
|
||||||
|
state.tion.heater = value;
|
||||||
|
drawToggle("#heater-toggle", value);
|
||||||
|
setText("#heater-detail", value ? "Автоматически" : "Выключен");
|
||||||
|
act(`/api/tion/heater/${value ? "on" : "off"}`, event.currentTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#open-panel").addEventListener("click", () => window.open(panelUrl(themeMode()), "_blank", "noopener"));
|
||||||
|
$("#air-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "air"), "_blank", "noopener"));
|
||||||
|
$("#schedule-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "schedule"), "_blank", "noopener"));
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(() => !document.hidden && !busy && refresh(true), 3000);
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Tion 4S — панель управления</title>
|
||||||
|
<link rel="stylesheet" href="/ui/static/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/ui/static/css/panel.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient"></div>
|
||||||
|
<main class="climate-panel glass" aria-live="polite">
|
||||||
|
<header class="panel-header">
|
||||||
|
<div class="identity">
|
||||||
|
<h1>Tion 4S</h1>
|
||||||
|
<span id="connection" class="pill muted">Загрузка</span>
|
||||||
|
<span id="mode-pill" class="pill muted">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-tools">
|
||||||
|
<div class="ventilation-badge glass-tile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M3 7h9a3 3 0 1 0-2.6-4.5M3 12h15a3 3 0 1 1-2.7 4.3M3 17h7" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>
|
||||||
|
<span><strong>Приточная вентиляция</strong><small>Чистый воздух для вашего дома</small></span>
|
||||||
|
</div>
|
||||||
|
<button id="refresh-button" class="icon-button" type="button" title="Обновить данные">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66M20 5v6h-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="theme-button" class="icon-button" type="button" title="Сменить тему">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.64 5.64l1.42 1.42m9.88 9.88 1.42 1.42m0-12.72-1.42 1.42M7.06 16.94l-1.42 1.42" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="primary-controls" aria-label="Основное управление">
|
||||||
|
<article class="control-card glass-tile speed-card">
|
||||||
|
<div class="card-title"><svg class="ui-icon feature-icon fan-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#fan"></use></svg><span><h2>Скорость вентиляции</h2><p id="speed-note">Текущая ступень вентилятора</p></span></div>
|
||||||
|
<div class="hero-control">
|
||||||
|
<button class="hero-step" data-step="speed" data-delta="-1" type="button" aria-label="Уменьшить скорость">−</button>
|
||||||
|
<strong id="speed-value" class="hero-value">—</strong>
|
||||||
|
<button class="hero-step primary" data-step="speed" data-delta="1" type="button" aria-label="Увеличить скорость">+</button>
|
||||||
|
</div>
|
||||||
|
<input id="speed-range" class="range speed-range" type="range" min="1" max="6" step="1" value="3" aria-label="Скорость вентилятора">
|
||||||
|
<div class="speed-segments" aria-label="Выбор скорости">
|
||||||
|
<button type="button" data-speed="1" aria-label="Скорость 1"></button>
|
||||||
|
<button type="button" data-speed="2" aria-label="Скорость 2"></button>
|
||||||
|
<button type="button" data-speed="3" aria-label="Скорость 3"></button>
|
||||||
|
<button type="button" data-speed="4" aria-label="Скорость 4"></button>
|
||||||
|
<button type="button" data-speed="5" aria-label="Скорость 5"></button>
|
||||||
|
<button type="button" data-speed="6" aria-label="Скорость 6"></button>
|
||||||
|
</div>
|
||||||
|
<div class="speed-labels" aria-hidden="true"><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="control-card glass-tile temperature-card">
|
||||||
|
<div class="card-title"><svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg><span><h2>Цель (температура)</h2><p id="temperature-note">Желаемая температура воздуха</p></span></div>
|
||||||
|
<div class="hero-control">
|
||||||
|
<button class="hero-step" data-step="temperature" data-delta="-1" type="button" aria-label="Уменьшить температуру">−</button>
|
||||||
|
<strong class="hero-value"><span id="temperature-value">—</span>°</strong>
|
||||||
|
<button class="hero-step primary" data-step="temperature" data-delta="1" type="button" aria-label="Увеличить температуру">+</button>
|
||||||
|
</div>
|
||||||
|
<input id="temperature-range" class="range temperature-range" type="range" min="5" max="30" step="1" value="21" aria-label="Целевая температура">
|
||||||
|
<div class="temperature-labels" aria-hidden="true"><span>5°</span><span>10°</span><span>15°</span><span>20°</span><span>25°</span><span>30°</span></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="switch-card glass-tile power-card">
|
||||||
|
<div class="card-title compact"><svg class="ui-icon feature-icon green" aria-hidden="true"><use href="/ui/static/assets/icons.svg#power"></use></svg><span><h2>Питание</h2><p id="power-caption">Устройство выключено</p></span></div>
|
||||||
|
<button id="power-toggle" class="glass-switch green" type="button" aria-pressed="false">OFF</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="switch-card glass-tile heater-card">
|
||||||
|
<div class="card-title compact"><svg class="ui-icon feature-icon orange" aria-hidden="true"><use href="/ui/static/assets/icons.svg#heat"></use></svg><span><h2>Обогрев</h2><p id="heater-caption">Поддержание температуры</p></span></div>
|
||||||
|
<button id="heater-toggle" class="glass-switch orange" type="button" aria-pressed="false">OFF</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="air" class="content-section glass-section" aria-labelledby="air-title">
|
||||||
|
<div class="section-title">
|
||||||
|
<div><svg class="ui-icon section-icon leaf" aria-hidden="true"><use href="/ui/static/assets/icons.svg#leaf"></use></svg><h2 id="air-title">Показатели воздуха</h2></div>
|
||||||
|
<span id="sensor-status" class="pill muted">Датчик</span>
|
||||||
|
<span id="updated-at" class="updated">Обновление…</span>
|
||||||
|
</div>
|
||||||
|
<div class="air-grid">
|
||||||
|
<article class="metric-card co2-card glass-tile" data-air-tone="muted">
|
||||||
|
<div class="metric-head"><span>CO₂</span><span id="air-label" class="quality-chip muted">Нет данных</span></div>
|
||||||
|
<strong><span id="co2">—</span> <small>ppm</small></strong>
|
||||||
|
<p id="air-advice">Ожидаем показания датчика</p>
|
||||||
|
<div class="meter co2-meter"><i id="co2-progress"></i></div>
|
||||||
|
<div class="meter-labels"><span>400</span><span>800</span><span>1200</span><span>2000</span></div>
|
||||||
|
</article>
|
||||||
|
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg><span>Температура<br>в помещении</span></div><strong><span id="room-temp">—</span>°</strong></article>
|
||||||
|
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#drop"></use></svg><span>Влажность</span></div><strong><span id="humidity">—</span>%</strong></article>
|
||||||
|
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon particles" aria-hidden="true"><use href="/ui/static/assets/icons.svg#particles"></use></svg><span>PM2.5</span></div><strong><span id="pm25">—</span> <small>мкг/м³</small></strong><span id="pm25-quality" class="quality-chip small">—</span></article>
|
||||||
|
<article class="metric-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon particles" aria-hidden="true"><use href="/ui/static/assets/icons.svg#particles"></use></svg><span>PM10</span></div><strong><span id="pm10">—</span> <small>мкг/м³</small></strong><span id="pm10-quality" class="quality-chip small">—</span></article>
|
||||||
|
<article class="metric-card filter-card glass-tile"><div class="metric-heading"><svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#filter"></use></svg><span>Фильтр</span></div><strong><span id="filter-remain">—</span> <small>дней осталось</small></strong><div class="filter-line"><div class="meter filter-meter"><i id="filter-progress"></i></div><span id="filter-percent">—</span></div></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="bottom-grid">
|
||||||
|
<section id="schedule" class="content-section glass-section schedule-section" aria-labelledby="schedule-title">
|
||||||
|
<div class="section-title"><div><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><h2 id="schedule-title">Расписание</h2></div><div class="schedule-title-tools"><button id="schedule-edit-button" class="schedule-edit-button" type="button"><svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#settings"></use></svg><span>Изменить</span></button><span id="schedule-state" class="pill muted">—</span></div></div>
|
||||||
|
<div class="schedule-layout">
|
||||||
|
<div class="schedule-times glass-tile">
|
||||||
|
<div><span>Текущая точка</span><strong id="current-time">—</strong><small id="current-action">Нет данных</small></div><i></i><div><span>Следующая точка</span><strong id="next-time">—</strong><small id="next-action">Нет данных</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-buttons">
|
||||||
|
<button id="auto-button" class="schedule-button auto" type="button"><svg class="ui-icon action-symbol" aria-hidden="true"><use href="/ui/static/assets/icons.svg#auto"></use></svg><span><strong>Вернуться в AUTO</strong><small>Отменить ручной режим</small></span></button>
|
||||||
|
<button id="pause-button" class="schedule-button pause" type="button"><svg class="ui-icon action-symbol" aria-hidden="true"><use href="/ui/static/assets/icons.svg#pause"></use></svg><span><strong>Пауза расписания</strong><small>Временно приостановить</small></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mode-banner" class="mode-banner warn"><span id="mode-title">Загрузка состояния</span><small id="mode-detail">Пожалуйста, подождите</small></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="content-section glass-section extras-section" aria-labelledby="extras-title">
|
||||||
|
<div class="section-title"><div><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#settings"></use></svg><h2 id="extras-title">Дополнительно</h2></div></div>
|
||||||
|
<div class="extras-grid">
|
||||||
|
<article class="extra-card glass-tile"><svg class="ui-icon extra-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#home"></use></svg><span><small>На улице</small><strong><span id="outside-temp">—</span>°</strong></span></article>
|
||||||
|
<article class="extra-card mode-card glass-tile">
|
||||||
|
<svg class="ui-icon extra-icon wind" aria-hidden="true"><use href="/ui/static/assets/icons.svg#wind"></use></svg><span><small>Режим работы</small><strong id="mode-name">Приточная вентиляция</strong><em>Подача и очистка воздуха</em></span>
|
||||||
|
<div class="segments" role="group" aria-label="Режим забора воздуха"><button id="mode-outside" class="segment" type="button">С улицы</button><button id="mode-recirculation" class="segment" type="button">Рециркуляция</button></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="micro-settings">
|
||||||
|
<span>Модель <strong id="model">—</strong></span><span>Батарея Qingping <strong><span id="battery">—</span>%</strong></span>
|
||||||
|
<button id="sound-toggle" class="micro-toggle" type="button" aria-pressed="false">Звук OFF</button><button id="light-toggle" class="micro-toggle" type="button" aria-pressed="false">Свет OFF</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<span id="demo-note" class="demo-note" hidden>Демонстрационный режим — команды не отправляются устройству</span>
|
||||||
|
</main>
|
||||||
|
<dialog id="schedule-dialog" class="schedule-dialog" aria-labelledby="schedule-dialog-title">
|
||||||
|
<form id="schedule-form" class="schedule-editor">
|
||||||
|
<header class="editor-header">
|
||||||
|
<span class="editor-heading"><svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><span><h2 id="schedule-dialog-title">Редактор расписания</h2><small>Изменения сохраняются в config/schedule.yaml</small></span></span>
|
||||||
|
<button id="schedule-close-button" class="editor-close" type="button" aria-label="Закрыть редактор">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="editor-scroll">
|
||||||
|
<label class="schedule-enabled"><input id="schedule-enabled" type="checkbox"><span><strong>Расписание включено</strong><small>Если выключить, автоматические точки применяться не будут</small></span></label>
|
||||||
|
|
||||||
|
<section class="editor-section" aria-labelledby="days-title">
|
||||||
|
<div class="editor-section-title"><span><h3 id="days-title">Дни недели</h3><small>Назначьте каждому дню один из шаблонов</small></span></div>
|
||||||
|
<div id="day-assignments" class="day-assignments"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-section" aria-labelledby="points-title">
|
||||||
|
<div class="editor-section-title template-toolbar">
|
||||||
|
<span><h3 id="points-title">Точки расписания</h3><small id="template-usage">Выберите шаблон</small></span>
|
||||||
|
<label>Шаблон<select id="template-select"></select></label>
|
||||||
|
<span class="template-actions"><button id="duplicate-template" type="button">Дублировать</button><button id="delete-template" type="button">Удалить</button></span>
|
||||||
|
</div>
|
||||||
|
<div id="schedule-points" class="schedule-points"></div>
|
||||||
|
<button id="add-schedule-point" class="add-schedule-point" type="button">+ Добавить точку</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<footer class="editor-footer">
|
||||||
|
<span id="schedule-editor-status" class="editor-status" role="status"></span>
|
||||||
|
<button id="schedule-cancel-button" class="editor-button secondary" type="button">Отмена</button>
|
||||||
|
<button id="schedule-save-button" class="editor-button primary" type="submit">Сохранить расписание</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<script type="module" src="/ui/static/js/panel.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Tion 4S — виджет</title>
|
||||||
|
<link rel="stylesheet" href="/ui/static/css/base.css">
|
||||||
|
<link rel="stylesheet" href="/ui/static/css/widget.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="ambient" aria-hidden="true"></div>
|
||||||
|
<main class="widget" aria-live="polite">
|
||||||
|
<header class="widget-header">
|
||||||
|
<div class="identity">
|
||||||
|
<span class="brand-icon" aria-hidden="true">
|
||||||
|
<svg class="ui-icon"><use href="/ui/static/assets/icons.svg#wind"></use></svg>
|
||||||
|
</span>
|
||||||
|
<span class="identity-copy">
|
||||||
|
<span class="title-row"><h1>Tion 4S</h1><span id="connection" class="pill muted">Загрузка</span></span>
|
||||||
|
<small>Приточная вентиляция</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button id="theme-button" class="theme-button" type="button" title="Сменить тему">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.64 5.64l1.42 1.42m9.88 9.88 1.42 1.42m0-12.72-1.42 1.42M7.06 16.94l-1.42 1.42" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.8"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="status-row">
|
||||||
|
<span id="mode-pill" class="pill muted">—</span>
|
||||||
|
<span id="mode-detail" class="mode-detail">Загрузка состояния</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="widget-tile control-card speed-card" aria-labelledby="speed-title">
|
||||||
|
<div class="card-title">
|
||||||
|
<svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#fan"></use></svg>
|
||||||
|
<span><h2 id="speed-title">Скорость вентиляции</h2><p id="speed-note">Ступень — из 6</p></span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-control">
|
||||||
|
<button class="hero-step" data-step="speed" data-delta="-1" type="button" aria-label="Уменьшить скорость">−</button>
|
||||||
|
<strong id="speed" class="hero-value value skeleton">0</strong>
|
||||||
|
<button class="hero-step primary" data-step="speed" data-delta="1" type="button" aria-label="Увеличить скорость">+</button>
|
||||||
|
</div>
|
||||||
|
<div id="speed-segments" class="speed-segments" role="group" aria-label="Выбор скорости">
|
||||||
|
<button type="button" data-speed="1" aria-label="Скорость 1"></button>
|
||||||
|
<button type="button" data-speed="2" aria-label="Скорость 2"></button>
|
||||||
|
<button type="button" data-speed="3" aria-label="Скорость 3"></button>
|
||||||
|
<button type="button" data-speed="4" aria-label="Скорость 4"></button>
|
||||||
|
<button type="button" data-speed="5" aria-label="Скорость 5"></button>
|
||||||
|
<button type="button" data-speed="6" aria-label="Скорость 6"></button>
|
||||||
|
</div>
|
||||||
|
<div class="scale-labels" aria-hidden="true"><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span><span>6</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget-tile control-card temperature-card" aria-labelledby="temperature-title">
|
||||||
|
<div class="card-title">
|
||||||
|
<svg class="ui-icon feature-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg>
|
||||||
|
<span><h2 id="temperature-title">Целевая температура</h2><p id="heating-note">Желаемая температура воздуха</p></span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-control">
|
||||||
|
<button class="hero-step" data-step="temperature" data-delta="-1" type="button" aria-label="Уменьшить температуру">−</button>
|
||||||
|
<strong class="hero-value value"><span id="target-temp" class="skeleton">00</span>°</strong>
|
||||||
|
<button class="hero-step primary" data-step="temperature" data-delta="1" type="button" aria-label="Увеличить температуру">+</button>
|
||||||
|
</div>
|
||||||
|
<input id="temperature-range" class="temperature-range" type="range" min="5" max="30" step="1" value="21" aria-label="Целевая температура">
|
||||||
|
<div class="temperature-labels" aria-hidden="true"><span>5°</span><span>15°</span><span>20°</span><span>25°</span><span>30°</span></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="switch-grid" aria-label="Питание и обогрев">
|
||||||
|
<article class="widget-tile switch-card power-card">
|
||||||
|
<div class="switch-title">
|
||||||
|
<svg class="ui-icon switch-icon green" aria-hidden="true"><use href="/ui/static/assets/icons.svg#power"></use></svg>
|
||||||
|
<span><h2>Питание</h2><small id="power-detail">Устройство выключено</small></span>
|
||||||
|
</div>
|
||||||
|
<button id="power-toggle" class="glass-switch green" type="button" aria-pressed="false">OFF</button>
|
||||||
|
</article>
|
||||||
|
<article class="widget-tile switch-card heater-card">
|
||||||
|
<div class="switch-title">
|
||||||
|
<svg class="ui-icon switch-icon orange" aria-hidden="true"><use href="/ui/static/assets/icons.svg#heat"></use></svg>
|
||||||
|
<span><h2>Обогрев</h2><small id="heater-detail">Выключен</small></span>
|
||||||
|
</div>
|
||||||
|
<button id="heater-toggle" class="glass-switch orange" type="button" aria-pressed="false">OFF</button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget-tile info-card air-card" data-air-tone="muted" aria-labelledby="air-title">
|
||||||
|
<header class="section-title">
|
||||||
|
<span><svg class="ui-icon section-icon leaf" aria-hidden="true"><use href="/ui/static/assets/icons.svg#leaf"></use></svg><h2 id="air-title">Воздух в комнате</h2></span>
|
||||||
|
<button id="air-link" class="open-section" type="button" aria-label="Открыть показатели воздуха в панели">↗</button>
|
||||||
|
</header>
|
||||||
|
<div class="co2-summary">
|
||||||
|
<span class="co2-label">CO₂</span>
|
||||||
|
<strong><span id="co2" class="value">—</span> <small>ppm</small></strong>
|
||||||
|
<span id="air-label" class="quality-chip muted">Нет данных</span>
|
||||||
|
</div>
|
||||||
|
<div class="co2-meter" aria-hidden="true"><i id="co2-progress"></i></div>
|
||||||
|
<div class="meter-labels" aria-hidden="true"><span>400</span><span>800</span><span>1200</span><span>2000</span></div>
|
||||||
|
<div class="mini-metrics">
|
||||||
|
<div class="mini-metric">
|
||||||
|
<svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#thermometer"></use></svg>
|
||||||
|
<span><small>Температура</small><strong><span id="room-temp" class="value">—</span>°</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="mini-metric">
|
||||||
|
<svg class="ui-icon metric-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#drop"></use></svg>
|
||||||
|
<span><small>Влажность</small><strong><span id="humidity" class="value">—</span>%</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="widget-tile info-card schedule-card" aria-labelledby="schedule-title">
|
||||||
|
<header class="section-title">
|
||||||
|
<span><svg class="ui-icon section-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#clock"></use></svg><h2 id="schedule-title">Расписание</h2></span>
|
||||||
|
<button id="schedule-link" class="open-section" type="button" aria-label="Открыть расписание в панели">↗</button>
|
||||||
|
</header>
|
||||||
|
<div class="schedule-times">
|
||||||
|
<span><small>Сейчас</small><strong id="current-time" class="value">—</strong></span>
|
||||||
|
<i aria-hidden="true"></i>
|
||||||
|
<span><small>Следующая точка</small><strong id="next-time" class="value">—</strong></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="open-panel" class="open-panel" type="button">
|
||||||
|
<svg class="ui-icon" aria-hidden="true"><use href="/ui/static/assets/icons.svg#wind"></use></svg>
|
||||||
|
<span>Открыть полную панель</span><b aria-hidden="true">↗</b>
|
||||||
|
</button>
|
||||||
|
<span id="demo-note" class="demo-note" hidden>Демонстрационный режим — команды не отправляются устройству</span>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/ui/static/js/widget.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user