2 Commits
15 changed files with 725 additions and 60 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.14 (TionController)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.14 (ClimatController)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <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> </modules>
</component> </component>
</project> </project>
+8
View File
@@ -0,0 +1,8 @@
[version]
major = 1
minor = 3
patch = 0
[build]
date = "2026-09-20"
time = "14:17:42"
+27
View File
@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path as FilePath from pathlib import Path as FilePath
from app.qingping.service import QingpingService from app.qingping.service import QingpingService
from app.metrics import PrometheusMetricsService
from app.my_dataclasses import ( from app.my_dataclasses import (
TION_MAC, TION_MAC,
@@ -92,6 +93,8 @@ schedule_load_error: str | None = None
auto_controller: AutoController | None = None auto_controller: AutoController | None = None
auto_load_error: str | None = None auto_load_error: str | None = None
metrics_service: PrometheusMetricsService | None = None
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Application lifecycle # Application lifecycle
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@@ -102,6 +105,7 @@ async def lifespan(app: FastAPI):
global schedule_load_error global schedule_load_error
global auto_controller global auto_controller
global auto_load_error global auto_load_error
global metrics_service
await service.start() await service.start()
await qingping_service.start() await qingping_service.start()
@@ -167,9 +171,32 @@ async def lifespan(app: FastAPI):
except Exception as exc: except Exception as exc:
schedule_service = None schedule_service = None
schedule_load_error = f"{type(exc).__name__}: {exc}" 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 yield
finally: finally:
if metrics_service is not None:
await metrics_service.stop()
if schedule_service is not None: if schedule_service is not None:
await schedule_service.stop() await schedule_service.stop()
+1
View File
@@ -0,0 +1 @@
from .service import PrometheusMetricsService
+309
View File
@@ -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}")
+1 -5
View File
@@ -51,11 +51,6 @@ templates:
speed: 2 speed: 2
target_temp: 25 target_temp: 25
- time: "19:02"
action:
type: set
speed: 6
- time: "22:00" - time: "22:00"
action: action:
type: set type: set
@@ -79,3 +74,4 @@ days:
sat: weekend sat: weekend
sun: weekend sun: weekend
+64
View File
@@ -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()
+85 -12
View File
@@ -266,7 +266,8 @@ body::after {
.co2-card > p { height: 1rem; margin: .2rem 0 .55rem; color: var(--panel-muted); font-size: .58rem; overflow: hidden; } .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 { 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 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: .25rem; color: var(--panel-muted); font-size: .55rem; } .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-line { display: flex; align-items: center; gap: .45rem; margin-top: 1rem; }
.filter-meter { flex: 1; } .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-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); }
@@ -375,27 +376,33 @@ body::after {
} }
body { body {
background-color: #b7d5e3; background-color: #b7d5e3;
background-image: none;
}
:root[data-theme="dark"] body {
background-color: #091722;
background-image: none;
}
body::before {
inset: -1.25rem;
background-image: background-image:
linear-gradient(118deg, rgba(180,220,239,.16), rgba(238,247,251,.04) 45%, rgba(247,226,204,.1)), 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"); url("../assets/mountain-lake-light.jpg");
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
filter: blur(6px) saturate(108%);
transform: scale(1.025);
opacity: 1;
pointer-events: none;
} }
:root[data-theme="dark"] body { :root[data-theme="dark"] body::before {
background-color: #091722;
background-image: background-image:
linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)), 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"); url("../assets/mountain-lake-dark.jpg");
background-position: center; background-position: center;
background-size: cover; background-size: cover;
} filter: blur(7px) saturate(106%);
body::before { opacity: 1;
inset: 0;
background: linear-gradient(110deg, rgba(214,242,253,.16), transparent 35%, rgba(255,240,218,.1) 76%, transparent);
filter: none;
transform: none;
opacity: .75;
} }
body::after { body::after {
background: background:
@@ -405,7 +412,6 @@ body::after {
filter: blur(11px); filter: blur(11px);
opacity: .5; opacity: .5;
} }
:root[data-theme="dark"] body::before { background: linear-gradient(115deg, rgba(29,104,142,.1), transparent 40%, rgba(110,76,42,.08)); opacity: .7; }
:root[data-theme="dark"] body::after { opacity: .26; } :root[data-theme="dark"] body::after { opacity: .26; }
.ambient::before { opacity: .14; filter: blur(76px); } .ambient::before { opacity: .14; filter: blur(76px); }
.ambient::after { opacity: .11; filter: blur(82px); } .ambient::after { opacity: .11; filter: blur(82px); }
@@ -797,3 +803,70 @@ body::after {
.editor-status { width: 100%; } .editor-status { width: 100%; }
.schedule-edit-button span { display: none; } .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); }
+160 -25
View File
@@ -39,39 +39,37 @@ body {
justify-content: center; justify-content: center;
color: var(--widget-ink); color: var(--widget-ink);
background-color: #b7d5e3; background-color: #b7d5e3;
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;
background-attachment: fixed;
overflow-x: hidden; overflow-x: hidden;
isolation: isolate; isolation: isolate;
} }
:root[data-theme="dark"] body { :root[data-theme="dark"] body {
background-color: #091722; background-color: #091722;
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");
} }
body::before, body::before,
body::after { body::after {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0;
z-index: -2;
pointer-events: none; pointer-events: none;
} }
body::before { body::before {
background: linear-gradient(110deg, rgba(214,242,253,.16), transparent 35%, rgba(255,240,218,.1) 76%, transparent); inset: -1.25rem;
opacity: .74; 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 { body::after {
inset: 0;
z-index: -1;
background: background:
radial-gradient(circle at 11% 18%, rgba(255,255,255,.32) 0 .5%, transparent 5%), 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 64% 23%, rgba(255,244,218,.27) 0 .6%, transparent 6%),
@@ -80,7 +78,12 @@ body::after {
opacity: .5; opacity: .5;
} }
:root[data-theme="dark"] body::before { background: linear-gradient(115deg, rgba(29,104,142,.1), transparent 40%, rgba(110,76,42,.08)); opacity: .7; } :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; } :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::before { width: 32rem; height: 32rem; left: -13rem; top: -14rem; background: #c9f2ff; opacity: .14; filter: blur(76px); }
@@ -351,9 +354,27 @@ body::after {
:root[data-theme="dark"] .quality-chip.warn { color: #ffd59e; } :root[data-theme="dark"] .quality-chip.warn { color: #ffd59e; }
:root[data-theme="dark"] .quality-chip.bad { color: #ffc0c8; } :root[data-theme="dark"] .quality-chip.bad { color: #ffc0c8; }
.co2-meter { height: .68rem; border: 1px solid rgba(255,255,255,.78); border-radius: 999px; background: rgba(91,145,181,.17); box-shadow: inset 0 1px 3px rgba(25,68,98,.09); overflow: hidden; } .air-card {
.co2-meter i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #08a350, #36dc83); box-shadow: 0 0 10px rgba(18,199,104,.34), inset 0 1px 0 rgba(255,255,255,.55); transition: width .3s ease; } --air-color: #7896ac;
.meter-labels { display: flex; justify-content: space-between; margin-top: .26rem; padding: 0 .2rem; color: var(--widget-muted); font-size: .54rem; } --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-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; } .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)); } .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)); }
@@ -401,14 +422,25 @@ body::after {
: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 { 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"].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"] .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"] .co2-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"] .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); } :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 /* Wide monitor layout. The same markup stays vertical on narrow screens, so
controls and API behaviour cannot drift between two widget versions. */ controls and API behaviour cannot drift between two widget versions. */
:root[data-widget-layout="horizontal"] body {
padding: .36rem;
}
:root[data-widget-layout="horizontal"] .widget { :root[data-widget-layout="horizontal"] .widget {
width: min(100%, 76rem); width: min(100%, 76rem);
max-width: none; 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-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, .8fr) minmax(0, .8fr);
grid-template-areas: grid-template-areas:
"header header header header" "header header header header"
@@ -418,25 +450,128 @@ body::after {
"demo demo demo demo"; "demo demo demo demo";
align-items: stretch; align-items: stretch;
} }
:root[data-widget-layout="horizontal"] .widget-header { grid-area: header; grid-template-columns: minmax(0, 1fr) auto auto; } :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 .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 .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"] .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"] .speed-card { grid-area: speed; }
:root[data-widget-layout="horizontal"] .temperature-card { grid-area: temperature; } :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 { display: contents; }
:root[data-widget-layout="horizontal"] .switch-grid > * { z-index: 1; } :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"] .power-card { grid-area: power; }
:root[data-widget-layout="horizontal"] .heater-card { grid-area: heater; } :root[data-widget-layout="horizontal"] .heater-card { grid-area: heater; }
:root[data-widget-layout="horizontal"] .switch-card { min-height: 11.4rem; } :root[data-widget-layout="horizontal"] .switch-card {
:root[data-widget-layout="horizontal"] .air-card { grid-area: air; } 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"] .schedule-card { grid-area: schedule; }
:root[data-widget-layout="horizontal"] .air-card, :root[data-widget-layout="horizontal"] .air-card,
:root[data-widget-layout="horizontal"] .schedule-card { min-height: 9.6rem; } :root[data-widget-layout="horizontal"] .schedule-card { min-height: 6.9rem; }
:root[data-widget-layout="horizontal"] .open-panel { grid-area: open; } :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; } :root[data-widget-layout="horizontal"] .demo-note { grid-area: demo; }
button:disabled { opacity: .58; cursor: wait; transform: none !important; } button:disabled { opacity: .58; cursor: default; transform: none !important; }
@media (min-width: 560px) and (min-height: 900px) { @media (min-width: 560px) and (min-height: 900px) {
html { font-size: 17px; } html { font-size: 17px; }
+42
View File
@@ -220,6 +220,48 @@ export function airQuality(co2) {
return { label: "Нужна вентиляция", tone: "bad", pct: Math.min(100, 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) { export function modeInfo(status) {
const schedule = status?.schedule || {}; const schedule = status?.schedule || {};
if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" }; if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" };
+8 -4
View File
@@ -1,4 +1,4 @@
import { api, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js"; import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js";
const $ = selector => document.querySelector(selector); const $ = selector => document.querySelector(selector);
configureTheme($("#theme-button")); configureTheme($("#theme-button"));
@@ -27,7 +27,6 @@ if (isDemo) $("#demo-note").hidden = false;
function text(selector, value) { $(selector).textContent = value; } function text(selector, value) { $(selector).textContent = value; }
function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; } function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; }
function toneColor(tone) { return `var(--${tone === "bad" ? "red" : tone === "warn" ? "orange" : tone === "good" ? "green" : tone === "info" ? "blue" : "muted"})`; }
function toggle(selector, active, label = "") { function toggle(selector, active, label = "") {
const button = $(selector); const button = $(selector);
@@ -63,11 +62,16 @@ function actionLabel(value) {
function renderAir(sensor, tion) { function renderAir(sensor, tion) {
const quality = airQuality(sensor.co2); 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("#co2", numberOrDash(sensor.co2));
text("#air-label", quality.label); text("#air-label", quality.label);
text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика"); text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика");
$("#air-label").style.color = toneColor(quality.tone); $("#air-label").className = `quality-chip ${airTone}`;
$("#air-label").style.background = `var(--${quality.tone === "bad" ? "red" : quality.tone === "warn" ? "orange" : quality.tone === "good" ? "green" : "glass"}-soft)`; 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))}%`; $("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad"); setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad");
text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1)); text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
+8 -2
View File
@@ -1,4 +1,4 @@
import { api, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js"; import { api, airMeterPalette, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js";
const requestedLayout = new URLSearchParams(window.location.search).get("layout"); const requestedLayout = new URLSearchParams(window.location.search).get("layout");
const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null; const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null;
@@ -75,7 +75,13 @@ function render(status) {
setText("#co2", numberOrDash(sensor.co2)); setText("#co2", numberOrDash(sensor.co2));
setText("#air-label", quality.label); setText("#air-label", quality.label);
$("#air-label").className = `quality-chip ${toneClass(quality.tone)}`; 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))}%`; $("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`;
setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1)); setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1));
setText("#humidity", numberOrDash(sensor.humidity)); setText("#humidity", numberOrDash(sensor.humidity));
+3 -3
View File
@@ -80,8 +80,8 @@
<span id="updated-at" class="updated">Обновление…</span> <span id="updated-at" class="updated">Обновление…</span>
</div> </div>
<div class="air-grid"> <div class="air-grid">
<article class="metric-card co2-card glass-tile"> <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">Нет данных</span></div> <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> <strong><span id="co2"></span> <small>ppm</small></strong>
<p id="air-advice">Ожидаем показания датчика</p> <p id="air-advice">Ожидаем показания датчика</p>
<div class="meter co2-meter"><i id="co2-progress"></i></div> <div class="meter co2-meter"><i id="co2-progress"></i></div>
@@ -91,7 +91,7 @@
<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" 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>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 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> <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> </div>
</section> </section>
+1 -1
View File
@@ -82,7 +82,7 @@
</article> </article>
</section> </section>
<section class="widget-tile info-card air-card" aria-labelledby="air-title"> <section class="widget-tile info-card air-card" data-air-tone="muted" aria-labelledby="air-title">
<header class="section-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> <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> <button id="air-link" class="open-section" type="button" aria-label="Открыть показатели воздуха в панели"></button>