python-bsblan 3.1.2__tar.gz → 3.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/PKG-INFO +1 -1
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/pyproject.toml +1 -1
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/__init__.py +6 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/bsblan.py +47 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/constants.py +1 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/models.py +167 -1
- python_bsblan-3.1.3/tests/test_schedule_models.py +229 -0
- python_bsblan-3.1.3/tests/test_set_hot_water_schedule.py +174 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.editorconfig +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.gitattributes +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/CODE_OF_CONDUCT.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/CONTRIBUTING.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/LICENSE.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/copilot-instructions.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/labels.yml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/release-drafter.yml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/renovate.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/codeql.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/labels.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/linting.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/lock.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/pr-labels.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/release-drafter.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/release.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/stale.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/tests.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/typing.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.gitignore +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.nvmrc +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.pre-commit-config.yaml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.prettierignore +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.yamllint +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/AGENTS.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/CLAUDE.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/README.md +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/examples/control.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/examples/ruff.toml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/package-lock.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/package.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/sonar-project.properties +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/exceptions.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/py.typed +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/utility.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/__init__.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/conftest.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/device.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/dict_version.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/hot_water_state.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/info.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/password.txt +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/sensor.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/state.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/static_state.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/thermostat_hvac.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/thermostat_temp.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/time.json +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/ruff.toml +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_api_initialization.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_api_validation.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_auth.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_bsblan.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_bsblan_edge_cases.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_configuration.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_constants.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_context_manager.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_device.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_dhw_time_switch.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_entity_info.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_hot_water_additional.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_hotwater_state.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_info.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_initialization.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_reset_validation.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_sensor.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_set_hotwater.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_state.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_static_state.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_temperature_unit.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_temperature_validation.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_thermostat.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_time.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility_additional.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility_edge_cases.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_version_errors.py +0 -0
- {python_bsblan-3.1.2 → python_bsblan-3.1.3}/uv.lock +0 -0
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from .bsblan import BSBLAN, BSBLANConfig
|
|
4
4
|
from .exceptions import BSBLANAuthError, BSBLANConnectionError, BSBLANError
|
|
5
5
|
from .models import (
|
|
6
|
+
DaySchedule,
|
|
6
7
|
Device,
|
|
7
8
|
DeviceTime,
|
|
9
|
+
DHWSchedule,
|
|
8
10
|
DHWTimeSwitchPrograms,
|
|
9
11
|
HotWaterConfig,
|
|
10
12
|
HotWaterSchedule,
|
|
@@ -14,6 +16,7 @@ from .models import (
|
|
|
14
16
|
SetHotWaterParam,
|
|
15
17
|
State,
|
|
16
18
|
StaticState,
|
|
19
|
+
TimeSlot,
|
|
17
20
|
)
|
|
18
21
|
|
|
19
22
|
__all__ = [
|
|
@@ -22,7 +25,9 @@ __all__ = [
|
|
|
22
25
|
"BSBLANConfig",
|
|
23
26
|
"BSBLANConnectionError",
|
|
24
27
|
"BSBLANError",
|
|
28
|
+
"DHWSchedule",
|
|
25
29
|
"DHWTimeSwitchPrograms",
|
|
30
|
+
"DaySchedule",
|
|
26
31
|
"Device",
|
|
27
32
|
"DeviceTime",
|
|
28
33
|
"HotWaterConfig",
|
|
@@ -33,4 +38,5 @@ __all__ = [
|
|
|
33
38
|
"SetHotWaterParam",
|
|
34
39
|
"State",
|
|
35
40
|
"StaticState",
|
|
41
|
+
"TimeSlot",
|
|
36
42
|
]
|
|
@@ -29,6 +29,7 @@ from .constants import (
|
|
|
29
29
|
MAX_VALID_YEAR,
|
|
30
30
|
MIN_VALID_YEAR,
|
|
31
31
|
MULTI_PARAMETER_ERROR_MSG,
|
|
32
|
+
NO_SCHEDULE_ERROR_MSG,
|
|
32
33
|
NO_STATE_ERROR_MSG,
|
|
33
34
|
SESSION_NOT_INITIALIZED_ERROR_MSG,
|
|
34
35
|
SETTABLE_HOT_WATER_PARAMS,
|
|
@@ -45,8 +46,10 @@ from .exceptions import (
|
|
|
45
46
|
BSBLANVersionError,
|
|
46
47
|
)
|
|
47
48
|
from .models import (
|
|
49
|
+
DaySchedule,
|
|
48
50
|
Device,
|
|
49
51
|
DeviceTime,
|
|
52
|
+
DHWSchedule,
|
|
50
53
|
HotWaterConfig,
|
|
51
54
|
HotWaterSchedule,
|
|
52
55
|
HotWaterState,
|
|
@@ -930,6 +933,50 @@ class BSBLAN:
|
|
|
930
933
|
state = self._prepare_hot_water_state(params)
|
|
931
934
|
await self._set_device_state(state)
|
|
932
935
|
|
|
936
|
+
async def set_hot_water_schedule(self, schedule: DHWSchedule) -> None:
|
|
937
|
+
"""Set hot water time program schedules.
|
|
938
|
+
|
|
939
|
+
This method allows setting weekly DHW schedules using a type-safe
|
|
940
|
+
interface with TimeSlot and DaySchedule objects.
|
|
941
|
+
|
|
942
|
+
Example:
|
|
943
|
+
schedule = DHWSchedule(
|
|
944
|
+
monday=DaySchedule(slots=[
|
|
945
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
946
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
947
|
+
]),
|
|
948
|
+
tuesday=DaySchedule(slots=[
|
|
949
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
950
|
+
])
|
|
951
|
+
)
|
|
952
|
+
await client.set_hot_water_schedule(schedule)
|
|
953
|
+
|
|
954
|
+
Args:
|
|
955
|
+
schedule: DHWSchedule object containing the weekly schedule.
|
|
956
|
+
|
|
957
|
+
Raises:
|
|
958
|
+
BSBLANError: If no schedule is provided.
|
|
959
|
+
|
|
960
|
+
"""
|
|
961
|
+
if not schedule.has_any_schedule():
|
|
962
|
+
raise BSBLANError(NO_SCHEDULE_ERROR_MSG)
|
|
963
|
+
|
|
964
|
+
# Invert DHW_TIME_PROGRAM_PARAMS to get day_name -> param_id mapping
|
|
965
|
+
# Exclude standard_values as it's not a day of the week
|
|
966
|
+
day_param_map = {
|
|
967
|
+
v: k for k, v in DHW_TIME_PROGRAM_PARAMS.items() if v != "standard_values"
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
for day_name, param_id in day_param_map.items():
|
|
971
|
+
day_schedule: DaySchedule | None = getattr(schedule, day_name)
|
|
972
|
+
if day_schedule is not None:
|
|
973
|
+
state = {
|
|
974
|
+
"Parameter": param_id,
|
|
975
|
+
"Value": day_schedule.to_bsblan_format(),
|
|
976
|
+
"Type": "1",
|
|
977
|
+
}
|
|
978
|
+
await self._set_device_state(state)
|
|
979
|
+
|
|
933
980
|
def _prepare_hot_water_state(
|
|
934
981
|
self,
|
|
935
982
|
params: SetHotWaterParam,
|
|
@@ -133,6 +133,7 @@ VALID_HVAC_MODES: Final[set[int]] = {0, 1, 2, 3}
|
|
|
133
133
|
|
|
134
134
|
# Error Messages
|
|
135
135
|
NO_STATE_ERROR_MSG: Final[str] = "No state provided."
|
|
136
|
+
NO_SCHEDULE_ERROR_MSG: Final[str] = "No schedule provided."
|
|
136
137
|
VERSION_ERROR_MSG: Final[str] = "Version not supported"
|
|
137
138
|
FIRMWARE_VERSION_ERROR_MSG: Final[str] = "Firmware version not available"
|
|
138
139
|
TEMPERATURE_RANGE_ERROR_MSG: Final[str] = "Temperature range not initialized"
|
|
@@ -7,12 +7,178 @@ from contextlib import suppress
|
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from datetime import time
|
|
9
9
|
from enum import IntEnum
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Final
|
|
11
11
|
|
|
12
12
|
from mashumaro.mixins.json import DataClassJSONMixin
|
|
13
13
|
|
|
14
14
|
from bsblan.constants import TEMPERATURE_UNITS
|
|
15
15
|
|
|
16
|
+
# Maximum number of time slots per day supported by BSB-LAN
|
|
17
|
+
MAX_TIME_SLOTS_PER_DAY: Final[int] = 3
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class TimeSlot:
|
|
22
|
+
"""A single time slot with start and end time.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
start: Start time of the slot.
|
|
26
|
+
end: End time of the slot.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
>>> slot = TimeSlot(time(6, 0), time(8, 0))
|
|
30
|
+
>>> slot.to_bsblan_format()
|
|
31
|
+
'06:00-08:00'
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
start: time
|
|
36
|
+
end: time
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
"""Validate that start is before end."""
|
|
40
|
+
if self.start >= self.end:
|
|
41
|
+
msg = f"Start time {self.start} must be before end time {self.end}"
|
|
42
|
+
raise ValueError(msg)
|
|
43
|
+
|
|
44
|
+
def to_bsblan_format(self) -> str:
|
|
45
|
+
"""Convert to BSB-LAN format 'HH:MM-HH:MM'.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
str: Time slot in BSB-LAN format.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
return f"{self.start.strftime('%H:%M')}-{self.end.strftime('%H:%M')}"
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_bsblan_format(cls, value: str) -> TimeSlot:
|
|
55
|
+
"""Parse from BSB-LAN format 'HH:MM-HH:MM'.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
value: Time slot string in format 'HH:MM-HH:MM'.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
TimeSlot: Parsed time slot.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
ValueError: If the format is invalid.
|
|
65
|
+
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
start_str, end_str = value.split("-")
|
|
69
|
+
start_h, start_m = map(int, start_str.split(":"))
|
|
70
|
+
end_h, end_m = map(int, end_str.split(":"))
|
|
71
|
+
return cls(start=time(start_h, start_m), end=time(end_h, end_m))
|
|
72
|
+
except (ValueError, AttributeError) as e:
|
|
73
|
+
msg = f"Invalid time slot format: {value}"
|
|
74
|
+
raise ValueError(msg) from e
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class DaySchedule:
|
|
79
|
+
"""Schedule for a single day with up to 3 time slots (BSB-LAN limit).
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
slots: List of time slots for the day.
|
|
83
|
+
|
|
84
|
+
Example:
|
|
85
|
+
>>> schedule = DaySchedule(slots=[
|
|
86
|
+
... TimeSlot(time(6, 0), time(8, 0)),
|
|
87
|
+
... TimeSlot(time(17, 0), time(21, 0)),
|
|
88
|
+
... ])
|
|
89
|
+
>>> schedule.to_bsblan_format()
|
|
90
|
+
'06:00-08:00 17:00-21:00'
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
slots: list[TimeSlot] = field(default_factory=list)
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
"""Validate max 3 slots per day (BSB-LAN limitation)."""
|
|
98
|
+
if len(self.slots) > MAX_TIME_SLOTS_PER_DAY:
|
|
99
|
+
msg = (
|
|
100
|
+
f"BSB-LAN supports maximum {MAX_TIME_SLOTS_PER_DAY} time slots per day"
|
|
101
|
+
)
|
|
102
|
+
raise ValueError(msg)
|
|
103
|
+
|
|
104
|
+
def to_bsblan_format(self) -> str:
|
|
105
|
+
"""Convert to BSB-LAN string format like '06:00-08:00 17:00-21:00'.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
str: Day schedule in BSB-LAN format, or empty string if no slots.
|
|
109
|
+
|
|
110
|
+
"""
|
|
111
|
+
if not self.slots:
|
|
112
|
+
return ""
|
|
113
|
+
return " ".join(slot.to_bsblan_format() for slot in self.slots)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_bsblan_format(cls, value: str) -> DaySchedule:
|
|
117
|
+
"""Parse from BSB-LAN format like '06:00-08:00 17:00-21:00'.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
value: Day schedule string in BSB-LAN format.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
DaySchedule: Parsed day schedule.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
if not value or value == "---":
|
|
127
|
+
return cls(slots=[])
|
|
128
|
+
slot_strings = value.split()
|
|
129
|
+
slots = [TimeSlot.from_bsblan_format(s) for s in slot_strings]
|
|
130
|
+
return cls(slots=slots)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class DHWSchedule:
|
|
135
|
+
"""Weekly hot water schedule for setting time programs.
|
|
136
|
+
|
|
137
|
+
Use this dataclass to set DHW time programs via set_hot_water_schedule().
|
|
138
|
+
Each day can have up to 3 time slots.
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
>>> schedule = DHWSchedule(
|
|
142
|
+
... monday=DaySchedule(slots=[
|
|
143
|
+
... TimeSlot(time(6, 0), time(8, 0)),
|
|
144
|
+
... TimeSlot(time(17, 0), time(21, 0)),
|
|
145
|
+
... ]),
|
|
146
|
+
... tuesday=DaySchedule(slots=[
|
|
147
|
+
... TimeSlot(time(6, 0), time(8, 0)),
|
|
148
|
+
... ])
|
|
149
|
+
... )
|
|
150
|
+
>>> await client.set_hot_water_schedule(schedule)
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
monday: DaySchedule | None = None
|
|
155
|
+
tuesday: DaySchedule | None = None
|
|
156
|
+
wednesday: DaySchedule | None = None
|
|
157
|
+
thursday: DaySchedule | None = None
|
|
158
|
+
friday: DaySchedule | None = None
|
|
159
|
+
saturday: DaySchedule | None = None
|
|
160
|
+
sunday: DaySchedule | None = None
|
|
161
|
+
|
|
162
|
+
def has_any_schedule(self) -> bool:
|
|
163
|
+
"""Check if any day has a schedule set.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
bool: True if at least one day has a schedule.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
return any(
|
|
170
|
+
day is not None
|
|
171
|
+
for day in [
|
|
172
|
+
self.monday,
|
|
173
|
+
self.tuesday,
|
|
174
|
+
self.wednesday,
|
|
175
|
+
self.thursday,
|
|
176
|
+
self.friday,
|
|
177
|
+
self.saturday,
|
|
178
|
+
self.sunday,
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
|
|
16
182
|
|
|
17
183
|
@dataclass
|
|
18
184
|
class DHWTimeSwitchPrograms:
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Test cases for schedule models (TimeSlot, DaySchedule, DHWSchedule)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import time
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from bsblan.models import DaySchedule, DHWSchedule, TimeSlot
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestTimeSlot:
|
|
13
|
+
"""Test cases for TimeSlot dataclass."""
|
|
14
|
+
|
|
15
|
+
def test_valid_time_slot(self) -> None:
|
|
16
|
+
"""Test creating a valid time slot."""
|
|
17
|
+
slot = TimeSlot(start=time(6, 0), end=time(8, 0))
|
|
18
|
+
assert slot.start == time(6, 0)
|
|
19
|
+
assert slot.end == time(8, 0)
|
|
20
|
+
|
|
21
|
+
def test_time_slot_to_bsblan_format(self) -> None:
|
|
22
|
+
"""Test converting time slot to BSB-LAN format."""
|
|
23
|
+
slot = TimeSlot(start=time(6, 0), end=time(8, 0))
|
|
24
|
+
assert slot.to_bsblan_format() == "06:00-08:00"
|
|
25
|
+
|
|
26
|
+
def test_time_slot_to_bsblan_format_with_minutes(self) -> None:
|
|
27
|
+
"""Test converting time slot with non-zero minutes."""
|
|
28
|
+
slot = TimeSlot(start=time(6, 30), end=time(8, 45))
|
|
29
|
+
assert slot.to_bsblan_format() == "06:30-08:45"
|
|
30
|
+
|
|
31
|
+
def test_time_slot_from_bsblan_format(self) -> None:
|
|
32
|
+
"""Test parsing time slot from BSB-LAN format."""
|
|
33
|
+
slot = TimeSlot.from_bsblan_format("06:00-08:00")
|
|
34
|
+
assert slot.start == time(6, 0)
|
|
35
|
+
assert slot.end == time(8, 0)
|
|
36
|
+
|
|
37
|
+
def test_time_slot_from_bsblan_format_with_minutes(self) -> None:
|
|
38
|
+
"""Test parsing time slot with non-zero minutes."""
|
|
39
|
+
slot = TimeSlot.from_bsblan_format("17:30-21:45")
|
|
40
|
+
assert slot.start == time(17, 30)
|
|
41
|
+
assert slot.end == time(21, 45)
|
|
42
|
+
|
|
43
|
+
def test_time_slot_invalid_start_after_end(self) -> None:
|
|
44
|
+
"""Test that start time must be before end time."""
|
|
45
|
+
with pytest.raises(ValueError, match="must be before end time"):
|
|
46
|
+
TimeSlot(start=time(10, 0), end=time(8, 0))
|
|
47
|
+
|
|
48
|
+
def test_time_slot_invalid_start_equals_end(self) -> None:
|
|
49
|
+
"""Test that start time cannot equal end time."""
|
|
50
|
+
with pytest.raises(ValueError, match="must be before end time"):
|
|
51
|
+
TimeSlot(start=time(8, 0), end=time(8, 0))
|
|
52
|
+
|
|
53
|
+
def test_time_slot_from_invalid_format(self) -> None:
|
|
54
|
+
"""Test parsing invalid format raises ValueError."""
|
|
55
|
+
with pytest.raises(ValueError, match="Invalid time slot format"):
|
|
56
|
+
TimeSlot.from_bsblan_format("invalid")
|
|
57
|
+
|
|
58
|
+
def test_time_slot_from_invalid_format_missing_dash(self) -> None:
|
|
59
|
+
"""Test parsing format without dash raises ValueError."""
|
|
60
|
+
with pytest.raises(ValueError, match="Invalid time slot format"):
|
|
61
|
+
TimeSlot.from_bsblan_format("06:00 08:00")
|
|
62
|
+
|
|
63
|
+
def test_time_slot_from_invalid_format_bad_time(self) -> None:
|
|
64
|
+
"""Test parsing format with bad time values raises ValueError."""
|
|
65
|
+
with pytest.raises(ValueError, match="Invalid time slot format"):
|
|
66
|
+
TimeSlot.from_bsblan_format("25:00-08:00")
|
|
67
|
+
|
|
68
|
+
def test_time_slot_roundtrip(self) -> None:
|
|
69
|
+
"""Test that converting to and from BSB-LAN format preserves values."""
|
|
70
|
+
original = TimeSlot(start=time(6, 30), end=time(17, 45))
|
|
71
|
+
bsblan_str = original.to_bsblan_format()
|
|
72
|
+
parsed = TimeSlot.from_bsblan_format(bsblan_str)
|
|
73
|
+
assert parsed.start == original.start
|
|
74
|
+
assert parsed.end == original.end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestDaySchedule:
|
|
78
|
+
"""Test cases for DaySchedule dataclass."""
|
|
79
|
+
|
|
80
|
+
def test_empty_day_schedule(self) -> None:
|
|
81
|
+
"""Test creating an empty day schedule."""
|
|
82
|
+
schedule = DaySchedule()
|
|
83
|
+
assert schedule.slots == []
|
|
84
|
+
|
|
85
|
+
def test_day_schedule_with_slots(self) -> None:
|
|
86
|
+
"""Test creating a day schedule with time slots."""
|
|
87
|
+
slots = [
|
|
88
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
89
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
90
|
+
]
|
|
91
|
+
schedule = DaySchedule(slots=slots)
|
|
92
|
+
assert len(schedule.slots) == 2
|
|
93
|
+
|
|
94
|
+
def test_day_schedule_to_bsblan_format(self) -> None:
|
|
95
|
+
"""Test converting day schedule to BSB-LAN format."""
|
|
96
|
+
schedule = DaySchedule(
|
|
97
|
+
slots=[
|
|
98
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
99
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
assert schedule.to_bsblan_format() == "06:00-08:00 17:00-21:00"
|
|
103
|
+
|
|
104
|
+
def test_day_schedule_to_bsblan_format_empty(self) -> None:
|
|
105
|
+
"""Test converting empty day schedule to BSB-LAN format."""
|
|
106
|
+
schedule = DaySchedule()
|
|
107
|
+
assert schedule.to_bsblan_format() == ""
|
|
108
|
+
|
|
109
|
+
def test_day_schedule_to_bsblan_format_single_slot(self) -> None:
|
|
110
|
+
"""Test converting day schedule with single slot."""
|
|
111
|
+
schedule = DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
|
|
112
|
+
assert schedule.to_bsblan_format() == "06:00-08:00"
|
|
113
|
+
|
|
114
|
+
def test_day_schedule_from_bsblan_format(self) -> None:
|
|
115
|
+
"""Test parsing day schedule from BSB-LAN format."""
|
|
116
|
+
schedule = DaySchedule.from_bsblan_format("06:00-08:00 17:00-21:00")
|
|
117
|
+
assert len(schedule.slots) == 2
|
|
118
|
+
assert schedule.slots[0].start == time(6, 0)
|
|
119
|
+
assert schedule.slots[1].start == time(17, 0)
|
|
120
|
+
|
|
121
|
+
def test_day_schedule_from_bsblan_format_empty(self) -> None:
|
|
122
|
+
"""Test parsing empty string returns empty schedule."""
|
|
123
|
+
schedule = DaySchedule.from_bsblan_format("")
|
|
124
|
+
assert schedule.slots == []
|
|
125
|
+
|
|
126
|
+
def test_day_schedule_from_bsblan_format_undefined(self) -> None:
|
|
127
|
+
"""Test parsing '---' returns empty schedule."""
|
|
128
|
+
schedule = DaySchedule.from_bsblan_format("---")
|
|
129
|
+
assert schedule.slots == []
|
|
130
|
+
|
|
131
|
+
def test_day_schedule_max_slots_valid(self) -> None:
|
|
132
|
+
"""Test that 3 slots (BSB-LAN max) is valid."""
|
|
133
|
+
schedule = DaySchedule(
|
|
134
|
+
slots=[
|
|
135
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
136
|
+
TimeSlot(time(12, 0), time(13, 0)),
|
|
137
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
assert len(schedule.slots) == 3
|
|
141
|
+
|
|
142
|
+
def test_day_schedule_too_many_slots(self) -> None:
|
|
143
|
+
"""Test that more than 3 slots raises ValueError."""
|
|
144
|
+
with pytest.raises(ValueError, match="maximum 3 time slots per day"):
|
|
145
|
+
DaySchedule(
|
|
146
|
+
slots=[
|
|
147
|
+
TimeSlot(time(6, 0), time(7, 0)),
|
|
148
|
+
TimeSlot(time(8, 0), time(9, 0)),
|
|
149
|
+
TimeSlot(time(10, 0), time(11, 0)),
|
|
150
|
+
TimeSlot(time(12, 0), time(13, 0)),
|
|
151
|
+
]
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def test_day_schedule_roundtrip(self) -> None:
|
|
155
|
+
"""Test that converting to and from BSB-LAN format preserves values."""
|
|
156
|
+
original = DaySchedule(
|
|
157
|
+
slots=[
|
|
158
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
159
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
bsblan_str = original.to_bsblan_format()
|
|
163
|
+
parsed = DaySchedule.from_bsblan_format(bsblan_str)
|
|
164
|
+
assert len(parsed.slots) == len(original.slots)
|
|
165
|
+
for orig_slot, parsed_slot in zip(original.slots, parsed.slots, strict=True):
|
|
166
|
+
assert orig_slot.start == parsed_slot.start
|
|
167
|
+
assert orig_slot.end == parsed_slot.end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestDHWSchedule:
|
|
171
|
+
"""Test cases for DHWSchedule dataclass."""
|
|
172
|
+
|
|
173
|
+
def test_empty_dhw_schedule(self) -> None:
|
|
174
|
+
"""Test creating an empty DHW schedule."""
|
|
175
|
+
schedule = DHWSchedule()
|
|
176
|
+
assert schedule.monday is None
|
|
177
|
+
assert schedule.tuesday is None
|
|
178
|
+
assert not schedule.has_any_schedule()
|
|
179
|
+
|
|
180
|
+
def test_dhw_schedule_with_days(self) -> None:
|
|
181
|
+
"""Test creating a DHW schedule with multiple days."""
|
|
182
|
+
monday = DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
|
|
183
|
+
tuesday = DaySchedule(slots=[TimeSlot(time(7, 0), time(9, 0))])
|
|
184
|
+
schedule = DHWSchedule(monday=monday, tuesday=tuesday)
|
|
185
|
+
assert schedule.monday is not None
|
|
186
|
+
assert schedule.tuesday is not None
|
|
187
|
+
assert schedule.wednesday is None
|
|
188
|
+
|
|
189
|
+
def test_dhw_schedule_has_any_schedule_true(self) -> None:
|
|
190
|
+
"""Test has_any_schedule returns True when a day is set."""
|
|
191
|
+
schedule = DHWSchedule(
|
|
192
|
+
monday=DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
|
|
193
|
+
)
|
|
194
|
+
assert schedule.has_any_schedule() is True
|
|
195
|
+
|
|
196
|
+
def test_dhw_schedule_has_any_schedule_false(self) -> None:
|
|
197
|
+
"""Test has_any_schedule returns False when no days are set."""
|
|
198
|
+
schedule = DHWSchedule()
|
|
199
|
+
assert schedule.has_any_schedule() is False
|
|
200
|
+
|
|
201
|
+
def test_dhw_schedule_all_days(self) -> None:
|
|
202
|
+
"""Test setting all days of the week."""
|
|
203
|
+
day = DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
|
|
204
|
+
schedule = DHWSchedule(
|
|
205
|
+
monday=day,
|
|
206
|
+
tuesday=day,
|
|
207
|
+
wednesday=day,
|
|
208
|
+
thursday=day,
|
|
209
|
+
friday=day,
|
|
210
|
+
saturday=day,
|
|
211
|
+
sunday=day,
|
|
212
|
+
)
|
|
213
|
+
assert schedule.has_any_schedule() is True
|
|
214
|
+
assert schedule.monday is not None
|
|
215
|
+
assert schedule.sunday is not None
|
|
216
|
+
|
|
217
|
+
def test_dhw_schedule_weekend_only(self) -> None:
|
|
218
|
+
"""Test setting only weekend days."""
|
|
219
|
+
weekend = DaySchedule(
|
|
220
|
+
slots=[
|
|
221
|
+
TimeSlot(time(8, 0), time(10, 0)),
|
|
222
|
+
TimeSlot(time(18, 0), time(22, 0)),
|
|
223
|
+
]
|
|
224
|
+
)
|
|
225
|
+
schedule = DHWSchedule(saturday=weekend, sunday=weekend)
|
|
226
|
+
assert schedule.saturday is not None
|
|
227
|
+
assert schedule.sunday is not None
|
|
228
|
+
assert schedule.monday is None
|
|
229
|
+
assert schedule.has_any_schedule() is True
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for set_hot_water_schedule method."""
|
|
2
|
+
|
|
3
|
+
# pylint: disable=duplicate-code
|
|
4
|
+
# pylint: disable=protected-access
|
|
5
|
+
# file deepcode ignore W0212: this is a testfile
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import time
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from unittest.mock import AsyncMock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from bsblan.exceptions import BSBLANError
|
|
16
|
+
from bsblan.models import DaySchedule, DHWSchedule, TimeSlot
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from bsblan import BSBLAN
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.asyncio
|
|
23
|
+
async def test_set_hot_water_schedule_single_day(mock_bsblan: BSBLAN) -> None:
|
|
24
|
+
"""Test setting hot water schedule for a single day.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
28
|
+
|
|
29
|
+
"""
|
|
30
|
+
schedule = DHWSchedule(
|
|
31
|
+
monday=DaySchedule(
|
|
32
|
+
slots=[
|
|
33
|
+
TimeSlot(time(6, 0), time(8, 0)),
|
|
34
|
+
TimeSlot(time(17, 0), time(21, 0)),
|
|
35
|
+
]
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert isinstance(mock_bsblan._request, AsyncMock)
|
|
40
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
41
|
+
|
|
42
|
+
# Verify the request was made correctly for Monday (param 561)
|
|
43
|
+
mock_bsblan._request.assert_awaited_with(
|
|
44
|
+
base_path="/JS",
|
|
45
|
+
data={
|
|
46
|
+
"Parameter": "561",
|
|
47
|
+
"Value": "06:00-08:00 17:00-21:00",
|
|
48
|
+
"Type": "1",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
async def test_set_hot_water_schedule_multiple_days(mock_bsblan: BSBLAN) -> None:
|
|
55
|
+
"""Test setting hot water schedule for multiple days.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
schedule = DHWSchedule(
|
|
62
|
+
monday=DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))]),
|
|
63
|
+
friday=DaySchedule(slots=[TimeSlot(time(7, 0), time(9, 0))]),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
assert isinstance(mock_bsblan._request, AsyncMock)
|
|
67
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
68
|
+
|
|
69
|
+
# Should have been called twice (once for Monday, once for Friday)
|
|
70
|
+
assert mock_bsblan._request.await_count == 2
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_set_hot_water_schedule_all_days(mock_bsblan: BSBLAN) -> None:
|
|
75
|
+
"""Test setting hot water schedule for all days.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
day = DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
|
|
82
|
+
schedule = DHWSchedule(
|
|
83
|
+
monday=day,
|
|
84
|
+
tuesday=day,
|
|
85
|
+
wednesday=day,
|
|
86
|
+
thursday=day,
|
|
87
|
+
friday=day,
|
|
88
|
+
saturday=day,
|
|
89
|
+
sunday=day,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
assert isinstance(mock_bsblan._request, AsyncMock)
|
|
93
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
94
|
+
|
|
95
|
+
# Should have been called 7 times (once for each day)
|
|
96
|
+
assert mock_bsblan._request.await_count == 7
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_set_hot_water_schedule_empty_raises_error(
|
|
101
|
+
mock_bsblan: BSBLAN,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Test that empty schedule raises BSBLANError.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
107
|
+
|
|
108
|
+
"""
|
|
109
|
+
schedule = DHWSchedule()
|
|
110
|
+
|
|
111
|
+
with pytest.raises(BSBLANError, match="No schedule provided"):
|
|
112
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.asyncio
|
|
116
|
+
async def test_set_hot_water_schedule_parameter_ids(mock_bsblan: BSBLAN) -> None:
|
|
117
|
+
"""Test that correct parameter IDs are used for each day.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
121
|
+
|
|
122
|
+
"""
|
|
123
|
+
# Expected parameter IDs for each day
|
|
124
|
+
expected_params = {
|
|
125
|
+
"monday": "561",
|
|
126
|
+
"tuesday": "562",
|
|
127
|
+
"wednesday": "563",
|
|
128
|
+
"thursday": "564",
|
|
129
|
+
"friday": "565",
|
|
130
|
+
"saturday": "566",
|
|
131
|
+
"sunday": "567",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for day_name, param_id in expected_params.items():
|
|
135
|
+
# Reset mock
|
|
136
|
+
assert isinstance(mock_bsblan._request, AsyncMock)
|
|
137
|
+
mock_bsblan._request.reset_mock()
|
|
138
|
+
|
|
139
|
+
# Create schedule with only this day
|
|
140
|
+
schedule = DHWSchedule(
|
|
141
|
+
**{day_name: DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])}
|
|
142
|
+
)
|
|
143
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
144
|
+
|
|
145
|
+
# Verify correct parameter ID was used
|
|
146
|
+
call_args = mock_bsblan._request.call_args
|
|
147
|
+
assert call_args is not None
|
|
148
|
+
assert call_args.kwargs["data"]["Parameter"] == param_id
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_set_hot_water_schedule_empty_day_schedule(
|
|
153
|
+
mock_bsblan: BSBLAN,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Test setting an empty day schedule (clears the schedule).
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
mock_bsblan: The mock BSBLAN instance.
|
|
159
|
+
|
|
160
|
+
"""
|
|
161
|
+
schedule = DHWSchedule(monday=DaySchedule(slots=[]))
|
|
162
|
+
|
|
163
|
+
assert isinstance(mock_bsblan._request, AsyncMock)
|
|
164
|
+
await mock_bsblan.set_hot_water_schedule(schedule)
|
|
165
|
+
|
|
166
|
+
# Should send empty string as value
|
|
167
|
+
mock_bsblan._request.assert_awaited_with(
|
|
168
|
+
base_path="/JS",
|
|
169
|
+
data={
|
|
170
|
+
"Parameter": "561",
|
|
171
|
+
"Value": "",
|
|
172
|
+
"Type": "1",
|
|
173
|
+
},
|
|
174
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|