Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d7a818e | ||
|
|
05539d91ed | ||
|
|
83d2eb619c | ||
|
|
8f749da14c | ||
|
|
3cd71ba5ff | ||
|
|
a5c9d07db5 | ||
|
|
b02d18d283 | ||
|
|
c6d92c10ee | ||
|
|
bff8efb85b | ||
|
|
f98cc2d6a3 | ||
|
|
cdec9b0996 | ||
|
|
7dbfd8b069 | ||
|
|
a3dafb4f59 | ||
|
|
dec14b1d12 | ||
|
|
4c665d9108 | ||
|
|
419b8efe33 | ||
|
|
fdae9dec72 | ||
|
|
5cc125db3f | ||
|
|
9241da63c3 |
+2
-1
@@ -2,9 +2,10 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+1
-1
@@ -2,7 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/TionController.iml" filepath="$PROJECT_DIR$/.idea/TionController.iml" />
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/ClimatController.iml" filepath="$PROJECT_DIR$/.idea/ClimatController.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
[version]
|
||||
major = 1
|
||||
minor = 3
|
||||
patch = 0
|
||||
|
||||
[build]
|
||||
date = "2026-09-20"
|
||||
time = "14:17:42"
|
||||
+917
@@ -0,0 +1,917 @@
|
||||
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.metrics import PrometheusMetricsService
|
||||
|
||||
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
|
||||
|
||||
metrics_service: PrometheusMetricsService | None = None
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Application lifecycle
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global schedule_service
|
||||
global schedule_load_error
|
||||
global auto_controller
|
||||
global auto_load_error
|
||||
global metrics_service
|
||||
|
||||
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}"
|
||||
|
||||
try:
|
||||
metrics_service = PrometheusMetricsService(
|
||||
tion_service=service,
|
||||
qingping_service=qingping_service,
|
||||
schedule_service=schedule_service,
|
||||
auto_controller=auto_controller,
|
||||
output_path=(
|
||||
"/var/lib/prometheus/"
|
||||
"node-exporter/"
|
||||
"climatcontroller.prom"
|
||||
),
|
||||
interval=5.0,
|
||||
)
|
||||
await metrics_service.start()
|
||||
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not start Prometheus metrics service")
|
||||
metrics_service = None
|
||||
|
||||
yield
|
||||
|
||||
finally:
|
||||
if metrics_service is not None:
|
||||
await metrics_service.stop()
|
||||
|
||||
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 @@
|
||||
from .service import PrometheusMetricsService
|
||||
@@ -0,0 +1,309 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PrometheusMetricsService:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tion_service,
|
||||
qingping_service,
|
||||
schedule_service=None,
|
||||
auto_controller=None,
|
||||
output_path: str | Path = (
|
||||
"/var/lib/prometheus/node-exporter/"
|
||||
"climatcontroller.prom"
|
||||
),
|
||||
interval: float = 5.0,
|
||||
):
|
||||
self._tion = tion_service
|
||||
self._qingping = qingping_service
|
||||
self._schedule = schedule_service
|
||||
self._auto = auto_controller
|
||||
|
||||
self._output_path = Path(output_path)
|
||||
self._interval = interval
|
||||
|
||||
self._running = False
|
||||
self._task = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._running
|
||||
|
||||
@property
|
||||
def last_error(self) -> str | None:
|
||||
return self._last_error
|
||||
|
||||
async def start(self) -> None:
|
||||
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
|
||||
await self._write_metrics()
|
||||
|
||||
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._interval
|
||||
)
|
||||
|
||||
try:
|
||||
await self._write_metrics()
|
||||
|
||||
except Exception as exc:
|
||||
self._last_error = f"{type(exc).__name__}: {exc}"
|
||||
logger.exception("Prometheus metrics update failed")
|
||||
|
||||
async def _write_metrics(self) -> None:
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# --------------------------------------------------
|
||||
# Время последнего успешного обновления файла.
|
||||
# Позволяет определить протухшие метрики.
|
||||
# --------------------------------------------------
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_metrics_timestamp_seconds",
|
||||
time.time(),
|
||||
"Unix timestamp of the last "
|
||||
"ClimatController metrics update",
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Tion
|
||||
# --------------------------------------------------
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_online",
|
||||
self._tion.online,
|
||||
"Tion connection state",
|
||||
)
|
||||
|
||||
tion_state = self._tion.state
|
||||
|
||||
if tion_state is not None:
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_power",
|
||||
tion_state.power,
|
||||
"Tion power state",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_heater",
|
||||
tion_state.heater,
|
||||
"Tion heater enabled state",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_heating",
|
||||
tion_state.heating,
|
||||
"Tion active heating state",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_fan_speed",
|
||||
tion_state.fan_speed,
|
||||
"Tion fan speed",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_in_temperature_celsius",
|
||||
tion_state.in_temp,
|
||||
"Tion inlet temperature",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_out_temperature_celsius",
|
||||
tion_state.out_temp,
|
||||
"Tion outside temperature",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_tion_target_temperature_celsius",
|
||||
tion_state.target_temp,
|
||||
"Tion target temperature",
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Qingping
|
||||
# --------------------------------------------------
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_online",
|
||||
self._qingping.online,
|
||||
"Qingping online state",
|
||||
)
|
||||
|
||||
qingping_state = self._qingping.state
|
||||
|
||||
if qingping_state is not None:
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_mqtt_connected",
|
||||
qingping_state.mqtt_connected,
|
||||
"Qingping MQTT connection state",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_temperature_celsius",
|
||||
qingping_state.temperature,
|
||||
"Qingping temperature",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_humidity_percent",
|
||||
qingping_state.humidity,
|
||||
"Qingping relative humidity",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_co2_ppm",
|
||||
qingping_state.co2,
|
||||
"Qingping CO2 concentration",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_pm25_ug_m3",
|
||||
qingping_state.pm25,
|
||||
"Qingping PM2.5 concentration",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_pm10_ug_m3",
|
||||
qingping_state.pm10,
|
||||
"Qingping PM10 concentration",
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_qingping_battery_percent",
|
||||
qingping_state.battery,
|
||||
"Qingping battery level",
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Schedule / AUTO
|
||||
# --------------------------------------------------
|
||||
|
||||
if self._schedule is not None:
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_schedule_paused",
|
||||
self._schedule.paused,
|
||||
"Schedule manual pause state",
|
||||
)
|
||||
|
||||
try:
|
||||
resolution = (
|
||||
self._schedule.resolve()
|
||||
)
|
||||
|
||||
self._add_gauge(
|
||||
lines,
|
||||
"climat_auto_active",
|
||||
resolution.auto_active,
|
||||
"AUTO schedule state",
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Schedule metrics resolve failed"
|
||||
)
|
||||
|
||||
# --------------------------------------------------
|
||||
# Atomic write
|
||||
# --------------------------------------------------
|
||||
|
||||
self._output_path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True,
|
||||
)
|
||||
|
||||
temp_path = (
|
||||
self._output_path.with_suffix(
|
||||
self._output_path.suffix
|
||||
+ ".tmp"
|
||||
)
|
||||
)
|
||||
|
||||
temp_path.write_text(
|
||||
"\n".join(lines) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
temp_path.replace(
|
||||
self._output_path
|
||||
)
|
||||
|
||||
self._last_error = None
|
||||
|
||||
@staticmethod
|
||||
def _add_gauge(
|
||||
lines: list[str],
|
||||
name: str,
|
||||
value,
|
||||
help_text: str,
|
||||
) -> None:
|
||||
|
||||
if value is None:
|
||||
return
|
||||
|
||||
if isinstance(value, bool):
|
||||
value = 1 if value else 0
|
||||
|
||||
lines.append(f"# HELP {name} {help_text}")
|
||||
|
||||
lines.append(f"# TYPE {name} gauge")
|
||||
|
||||
lines.append(f"{name} {value}")
|
||||
@@ -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,77 @@
|
||||
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: "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 для выполнения или замените его своим кодом.
|
||||
# Нажмите Двойное нажатие Shift для поиска везде: классы, файлы, окна инструментов, действия и настройки.
|
||||
import uvicorn
|
||||
|
||||
|
||||
def print_hi(name):
|
||||
# Используйте точку останова в строке кода ниже для отладки скрипта.
|
||||
print(f'Hi, {name}') # Нажмите Ctrl+F8 для переключения точки останова.
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Tion Controller web server")
|
||||
parser.add_argument("--host", default="0.0.0.0")
|
||||
parser.add_argument("--port", default=8000, type=int)
|
||||
parser.add_argument(
|
||||
"--reload",
|
||||
action="store_true",
|
||||
help="Reload the server after source file changes",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
# Нажмите зеленую кнопку на полях для запуска скрипта.
|
||||
if __name__ == '__main__':
|
||||
print_hi('PyCharm')
|
||||
|
||||
# Справка PyCharm доступна на https://www.jetbrains.com/help/pycharm/
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
uvicorn.run(
|
||||
"app.api:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=args.reload,
|
||||
)
|
||||
|
||||
@@ -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