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.
Files changed (89) hide show
  1. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/PKG-INFO +1 -1
  2. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/pyproject.toml +1 -1
  3. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/__init__.py +6 -0
  4. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/bsblan.py +47 -0
  5. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/constants.py +1 -0
  6. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/models.py +167 -1
  7. python_bsblan-3.1.3/tests/test_schedule_models.py +229 -0
  8. python_bsblan-3.1.3/tests/test_set_hot_water_schedule.py +174 -0
  9. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.editorconfig +0 -0
  10. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.gitattributes +0 -0
  11. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/CODE_OF_CONDUCT.md +0 -0
  12. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/CONTRIBUTING.md +0 -0
  13. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
  14. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  15. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  16. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/LICENSE.md +0 -0
  17. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/copilot-instructions.md +0 -0
  18. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/labels.yml +0 -0
  19. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/release-drafter.yml +0 -0
  20. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/renovate.json +0 -0
  21. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/codeql.yaml +0 -0
  22. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/labels.yaml +0 -0
  23. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/linting.yaml +0 -0
  24. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/lock.yaml +0 -0
  25. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/pr-labels.yaml +0 -0
  26. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/release-drafter.yaml +0 -0
  27. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/release.yaml +0 -0
  28. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/stale.yaml +0 -0
  29. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/tests.yaml +0 -0
  30. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.github/workflows/typing.yaml +0 -0
  31. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.gitignore +0 -0
  32. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.nvmrc +0 -0
  33. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.pre-commit-config.yaml +0 -0
  34. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.prettierignore +0 -0
  35. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/.yamllint +0 -0
  36. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/AGENTS.md +0 -0
  37. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/CLAUDE.md +0 -0
  38. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/README.md +0 -0
  39. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/examples/control.py +0 -0
  40. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/examples/ruff.toml +0 -0
  41. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/package-lock.json +0 -0
  42. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/package.json +0 -0
  43. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/sonar-project.properties +0 -0
  44. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/exceptions.py +0 -0
  45. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/py.typed +0 -0
  46. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/src/bsblan/utility.py +0 -0
  47. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/__init__.py +0 -0
  48. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/conftest.py +0 -0
  49. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/device.json +0 -0
  50. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/dict_version.json +0 -0
  51. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/hot_water_state.json +0 -0
  52. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/info.json +0 -0
  53. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/password.txt +0 -0
  54. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/sensor.json +0 -0
  55. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/state.json +0 -0
  56. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/static_state.json +0 -0
  57. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/thermostat_hvac.json +0 -0
  58. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/thermostat_temp.json +0 -0
  59. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/fixtures/time.json +0 -0
  60. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/ruff.toml +0 -0
  61. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_api_initialization.py +0 -0
  62. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_api_validation.py +0 -0
  63. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_auth.py +0 -0
  64. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_bsblan.py +0 -0
  65. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_bsblan_edge_cases.py +0 -0
  66. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_configuration.py +0 -0
  67. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_constants.py +0 -0
  68. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_context_manager.py +0 -0
  69. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_device.py +0 -0
  70. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_dhw_time_switch.py +0 -0
  71. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_entity_info.py +0 -0
  72. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_hot_water_additional.py +0 -0
  73. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_hotwater_state.py +0 -0
  74. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_info.py +0 -0
  75. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_initialization.py +0 -0
  76. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_reset_validation.py +0 -0
  77. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_sensor.py +0 -0
  78. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_set_hotwater.py +0 -0
  79. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_state.py +0 -0
  80. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_static_state.py +0 -0
  81. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_temperature_unit.py +0 -0
  82. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_temperature_validation.py +0 -0
  83. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_thermostat.py +0 -0
  84. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_time.py +0 -0
  85. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility.py +0 -0
  86. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility_additional.py +0 -0
  87. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_utility_edge_cases.py +0 -0
  88. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/tests/test_version_errors.py +0 -0
  89. {python_bsblan-3.1.2 → python_bsblan-3.1.3}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 3.1.2
3
+ Version: 3.1.3
4
4
  Summary: Asynchronous Python client for BSBLAN API
5
5
  Project-URL: Homepage, https://github.com/liudger/python-bsblan
6
6
  Project-URL: Repository, https://github.com/liudger/python-bsblan
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-bsblan"
3
- version = "3.1.2"
3
+ version = "3.1.3"
4
4
  description = "Asynchronous Python client for BSBLAN API"
5
5
  authors = [
6
6
  {name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
@@ -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