work: Добваил нагрев в автоматизацию

This commit is contained in:
Fedorov Dmitriy
2026-09-19 18:09:03 +03:00
parent bff8efb85b
commit c6d92c10ee
19 changed files with 2661 additions and 91 deletions
+154
View File
@@ -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())
+304
View File
@@ -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())
+245
View File
@@ -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())
+359
View File
@@ -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()
+116
View File
@@ -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()
+169
View File
@@ -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()
+327
View File
@@ -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()