python-bsblan 5.1.5__tar.gz → 5.2.0__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 (119) hide show
  1. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/PKG-INFO +1 -1
  2. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/client.md +2 -0
  3. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/models.md +8 -0
  4. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/pyproject.toml +1 -1
  5. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/__init__.py +4 -0
  6. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/bsblan.py +88 -0
  7. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/constants.py +28 -0
  8. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/models.py +58 -16
  9. python_bsblan-5.2.0/tests/test_heating_schedule.py +145 -0
  10. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_schedule_models.py +32 -1
  11. python_bsblan-5.2.0/tests/test_set_heating_schedule.py +150 -0
  12. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.editorconfig +0 -0
  13. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.gitattributes +0 -0
  14. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/CODE_OF_CONDUCT.md +0 -0
  15. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/CONTRIBUTING.md +0 -0
  16. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/SECURITY.md +0 -0
  20. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/copilot-instructions.md +0 -0
  21. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/labels.yml +0 -0
  22. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/prompts/add-parameter.prompt.md +0 -0
  23. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/prompts/code-review.prompt.md +0 -0
  24. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/release-drafter.yml +0 -0
  25. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/renovate.json +0 -0
  26. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/skills/bsblan-parameters/SKILL.md +0 -0
  27. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/skills/bsblan-testing/SKILL.md +0 -0
  28. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/auto-approve-renovate.yml +0 -0
  29. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/codeql.yaml +0 -0
  30. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/dependency-review.yaml +0 -0
  31. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/docs.yaml +0 -0
  32. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/labels.yaml +0 -0
  33. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/linting.yaml +0 -0
  34. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/lock.yaml +0 -0
  35. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/pr-labels.yaml +0 -0
  36. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/release-drafter.yaml +0 -0
  37. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/release.yaml +0 -0
  38. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/scorecard.yml +0 -0
  39. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/stale.yaml +0 -0
  40. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/tests.yaml +0 -0
  41. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/typing.yaml +0 -0
  42. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/zizmor.yml +0 -0
  43. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.gitignore +0 -0
  44. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.nvmrc +0 -0
  45. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.pre-commit-config.yaml +0 -0
  46. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.prettierignore +0 -0
  47. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.yamllint +0 -0
  48. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/AGENTS.md +0 -0
  49. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/CLAUDE.md +0 -0
  50. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/LICENSE.md +0 -0
  51. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/Makefile +0 -0
  52. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/README.md +0 -0
  53. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/constants.md +0 -0
  54. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/exceptions.md +0 -0
  55. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/getting-started.md +0 -0
  56. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/index.md +0 -0
  57. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/control.py +0 -0
  58. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/discovery.py +0 -0
  59. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/fetch_param.py +0 -0
  60. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/profile_init.py +0 -0
  61. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/ruff.toml +0 -0
  62. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/speed_test.py +0 -0
  63. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/mkdocs.yml +0 -0
  64. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/package-lock.json +0 -0
  65. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/package.json +0 -0
  66. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/sonar-project.properties +0 -0
  67. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/exceptions.py +0 -0
  68. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/py.typed +0 -0
  69. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/utility.py +0 -0
  70. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/__init__.py +0 -0
  71. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/conftest.py +0 -0
  72. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/device.json +0 -0
  73. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/dict_version.json +0 -0
  74. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/hot_water_state.json +0 -0
  75. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/info.json +0 -0
  76. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/password.txt +0 -0
  77. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/sensor.json +0 -0
  78. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/state.json +0 -0
  79. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/state_circuit2.json +0 -0
  80. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/static_state.json +0 -0
  81. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/static_state_circuit2.json +0 -0
  82. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/thermostat_hvac.json +0 -0
  83. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/thermostat_temp.json +0 -0
  84. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/time.json +0 -0
  85. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/ruff.toml +0 -0
  86. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_api_initialization.py +0 -0
  87. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_api_validation.py +0 -0
  88. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_auth.py +0 -0
  89. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_backoff_retry.py +0 -0
  90. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_bsblan.py +0 -0
  91. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_bsblan_edge_cases.py +0 -0
  92. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_circuit.py +0 -0
  93. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_configuration.py +0 -0
  94. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_constants.py +0 -0
  95. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_context_manager.py +0 -0
  96. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_device.py +0 -0
  97. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_dhw_time_switch.py +0 -0
  98. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_entity_info.py +0 -0
  99. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_entity_info_ha.py +0 -0
  100. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_hot_water_additional.py +0 -0
  101. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_hotwater_state.py +0 -0
  102. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_include_parameter.py +0 -0
  103. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_info.py +0 -0
  104. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_initialization.py +0 -0
  105. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_read_parameters.py +0 -0
  106. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_reset_validation.py +0 -0
  107. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_sensor.py +0 -0
  108. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_set_hot_water_schedule.py +0 -0
  109. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_set_hotwater.py +0 -0
  110. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_state.py +0 -0
  111. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_static_state.py +0 -0
  112. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_temperature_unit.py +0 -0
  113. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_temperature_validation.py +0 -0
  114. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_thermostat.py +0 -0
  115. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_time.py +0 -0
  116. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility.py +0 -0
  117. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility_additional.py +0 -0
  118. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility_edge_cases.py +0 -0
  119. {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_version_errors.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-bsblan
3
- Version: 5.1.5
3
+ Version: 5.2.0
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
@@ -19,6 +19,8 @@ The main entry point for interacting with BSB-LAN devices.
19
19
  - time
20
20
  - set_time
21
21
  - thermostat
22
+ - heating_schedule
23
+ - set_heating_schedule
22
24
  - hot_water_state
23
25
  - hot_water_config
24
26
  - hot_water_schedule
@@ -26,6 +26,10 @@ Data models for BSB-LAN API responses.
26
26
 
27
27
  ::: bsblan.Sensor
28
28
 
29
+ ### HeatingTimeSwitchPrograms
30
+
31
+ ::: bsblan.HeatingTimeSwitchPrograms
32
+
29
33
  ## Hot water
30
34
 
31
35
  ### HotWaterState
@@ -46,6 +50,10 @@ Data models for BSB-LAN API responses.
46
50
 
47
51
  ## Schedules
48
52
 
53
+ ### HeatingSchedule
54
+
55
+ ::: bsblan.HeatingSchedule
56
+
49
57
  ### DHWSchedule
50
58
 
51
59
  ::: bsblan.DHWSchedule
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-bsblan"
3
- version = "5.1.5"
3
+ version = "5.2.0"
4
4
  description = "Asynchronous Python client for BSBLAN API"
5
5
  authors = [
6
6
  {name = "Willem-Jan van Rootselaar", email = "liudgervr@gmail.com"}
@@ -17,6 +17,8 @@ from .models import (
17
17
  DHWTimeSwitchPrograms,
18
18
  EntityInfo,
19
19
  EntityValue,
20
+ HeatingSchedule,
21
+ HeatingTimeSwitchPrograms,
20
22
  HotWaterConfig,
21
23
  HotWaterSchedule,
22
24
  HotWaterState,
@@ -45,6 +47,8 @@ __all__ = [
45
47
  "EntityValue",
46
48
  "HVACActionCategory",
47
49
  "HeatingCircuitStatus",
50
+ "HeatingSchedule",
51
+ "HeatingTimeSwitchPrograms",
48
52
  "HotWaterConfig",
49
53
  "HotWaterSchedule",
50
54
  "HotWaterState",
@@ -22,6 +22,7 @@ from .constants import (
22
22
  APIConfig,
23
23
  CircuitConfig,
24
24
  ErrorMsg,
25
+ HeatingScheduleParams,
25
26
  HotWaterParams,
26
27
  Validation,
27
28
  )
@@ -38,6 +39,8 @@ from .models import (
38
39
  DeviceTime,
39
40
  DHWSchedule,
40
41
  EntityInfo,
42
+ HeatingSchedule,
43
+ HeatingTimeSwitchPrograms,
41
44
  HotWaterConfig,
42
45
  HotWaterSchedule,
43
46
  HotWaterState,
@@ -1380,6 +1383,91 @@ class BSBLAN:
1380
1383
  include=include,
1381
1384
  )
1382
1385
 
1386
+ async def heating_schedule(
1387
+ self,
1388
+ include: list[str] | None = None,
1389
+ circuit: int = 1,
1390
+ ) -> HeatingTimeSwitchPrograms:
1391
+ """Get heating time switch programs for a specific circuit.
1392
+
1393
+ Args:
1394
+ include: Optional list of day names to fetch. If None,
1395
+ fetches all schedule parameters. Valid names include:
1396
+ monday, tuesday, wednesday, thursday,
1397
+ friday, saturday, sunday, standard_values.
1398
+ circuit: The heating circuit number (1 or 2). Defaults to 1.
1399
+
1400
+ Returns:
1401
+ HeatingTimeSwitchPrograms: Heating schedule information.
1402
+
1403
+ """
1404
+ self._validate_circuit(circuit)
1405
+ time_program_params = HeatingScheduleParams.TIME_PROGRAMS[circuit]
1406
+
1407
+ filtered_params = time_program_params
1408
+ if include is not None:
1409
+ if not include:
1410
+ raise BSBLANError(ErrorMsg.EMPTY_INCLUDE_LIST)
1411
+ filtered_params = {
1412
+ param_id: name
1413
+ for param_id, name in time_program_params.items()
1414
+ if name in include
1415
+ }
1416
+ if not filtered_params:
1417
+ raise BSBLANError(ErrorMsg.INVALID_INCLUDE_PARAMS)
1418
+
1419
+ params = self._extract_params_summary(filtered_params)
1420
+ data = await self._request(params={"Parameter": params["string_par"]})
1421
+ mapped_data = {
1422
+ name: data[param_id]
1423
+ for param_id, name in filtered_params.items()
1424
+ if param_id in data
1425
+ }
1426
+
1427
+ if not mapped_data:
1428
+ raise BSBLANError(ErrorMsg.NO_HEATING_SCHEDULE_PARAMS)
1429
+
1430
+ return HeatingTimeSwitchPrograms.model_validate(mapped_data)
1431
+
1432
+ async def set_heating_schedule(
1433
+ self,
1434
+ schedule: HeatingSchedule,
1435
+ circuit: int = 1,
1436
+ ) -> None:
1437
+ """Set heating time switch programs for a specific circuit.
1438
+
1439
+ This method allows setting weekly heating schedules using a type-safe
1440
+ interface with TimeSlot and DaySchedule objects.
1441
+
1442
+ Args:
1443
+ schedule: HeatingSchedule object containing the weekly schedule.
1444
+ circuit: The heating circuit number (1 or 2). Defaults to 1.
1445
+
1446
+ Raises:
1447
+ BSBLANError: If no schedule is provided.
1448
+
1449
+ """
1450
+ self._validate_circuit(circuit)
1451
+
1452
+ if not schedule.has_any_schedule():
1453
+ raise BSBLANError(ErrorMsg.NO_SCHEDULE)
1454
+
1455
+ day_param_map = {
1456
+ v: k
1457
+ for k, v in HeatingScheduleParams.TIME_PROGRAMS[circuit].items()
1458
+ if v != "standard_values"
1459
+ }
1460
+
1461
+ for day_name, param_id in day_param_map.items():
1462
+ day_schedule: DaySchedule | None = getattr(schedule, day_name)
1463
+ if day_schedule is not None:
1464
+ state = {
1465
+ "Parameter": param_id,
1466
+ "Value": day_schedule.to_bsblan_format(),
1467
+ "Type": "1",
1468
+ }
1469
+ await self._set_device_state(state)
1470
+
1383
1471
  async def set_hot_water(self, params: SetHotWaterParam) -> None:
1384
1472
  """Change the state of the hot water system through BSB-Lan.
1385
1473
 
@@ -491,6 +491,7 @@ class ErrorMsg:
491
491
  EMPTY_INCLUDE_LIST = (
492
492
  "Empty include list provided. Use None to fetch all parameters."
493
493
  )
494
+ NO_HEATING_SCHEDULE_PARAMS = "No heating schedule parameters available"
494
495
 
495
496
 
496
497
  # Handle both ASCII and Unicode degree symbols
@@ -655,3 +656,30 @@ class HotWaterParams:
655
656
  "567": "sunday",
656
657
  "576": "standard_values",
657
658
  }
659
+
660
+
661
+ class HeatingScheduleParams:
662
+ """Heating schedule parameter mappings per circuit."""
663
+
664
+ TIME_PROGRAMS: Final[dict[int, dict[str, str]]] = {
665
+ 1: {
666
+ "501": "monday",
667
+ "502": "tuesday",
668
+ "503": "wednesday",
669
+ "504": "thursday",
670
+ "505": "friday",
671
+ "506": "saturday",
672
+ "507": "sunday",
673
+ "516": "standard_values",
674
+ },
675
+ 2: {
676
+ "521": "monday",
677
+ "522": "tuesday",
678
+ "523": "wednesday",
679
+ "524": "thursday",
680
+ "525": "friday",
681
+ "526": "saturday",
682
+ "527": "sunday",
683
+ "536": "standard_values",
684
+ },
685
+ }
@@ -141,23 +141,10 @@ class DaySchedule:
141
141
 
142
142
 
143
143
  @dataclass
144
- class DHWSchedule:
145
- """Weekly hot water schedule for setting time programs.
144
+ class WeeklySchedule:
145
+ """Base weekly schedule with optional day schedules.
146
146
 
147
- Use this dataclass to set DHW time programs via set_hot_water_schedule().
148
- Each day can have up to 3 time slots.
149
-
150
- Example:
151
- >>> schedule = DHWSchedule(
152
- ... monday=DaySchedule(slots=[
153
- ... TimeSlot(time(6, 0), time(8, 0)),
154
- ... TimeSlot(time(17, 0), time(21, 0)),
155
- ... ]),
156
- ... tuesday=DaySchedule(slots=[
157
- ... TimeSlot(time(6, 0), time(8, 0)),
158
- ... ])
159
- ... )
160
- >>> await client.set_hot_water_schedule(schedule)
147
+ Each day can have up to 3 time slots (validated by DaySchedule).
161
148
 
162
149
  """
163
150
 
@@ -190,6 +177,38 @@ class DHWSchedule:
190
177
  )
191
178
 
192
179
 
180
+ @dataclass
181
+ class DHWSchedule(WeeklySchedule):
182
+ """Weekly hot water schedule for setting time programs.
183
+
184
+ Use this dataclass to set DHW time programs via set_hot_water_schedule().
185
+ Each day can have up to 3 time slots.
186
+
187
+ Example:
188
+ >>> schedule = DHWSchedule(
189
+ ... monday=DaySchedule(slots=[
190
+ ... TimeSlot(time(6, 0), time(8, 0)),
191
+ ... TimeSlot(time(17, 0), time(21, 0)),
192
+ ... ]),
193
+ ... tuesday=DaySchedule(slots=[
194
+ ... TimeSlot(time(6, 0), time(8, 0)),
195
+ ... ])
196
+ ... )
197
+ >>> await client.set_hot_water_schedule(schedule)
198
+
199
+ """
200
+
201
+
202
+ @dataclass
203
+ class HeatingSchedule(WeeklySchedule):
204
+ """Weekly heating schedule for setting time programs.
205
+
206
+ Use this dataclass to set heating time programs via set_heating_schedule().
207
+ Each day can have up to 3 time slots.
208
+
209
+ """
210
+
211
+
193
212
  @dataclass
194
213
  class DHWTimeSwitchPrograms:
195
214
  """Dataclass for DHW time switch programs."""
@@ -554,6 +573,29 @@ class HotWaterSchedule(BaseModel):
554
573
  dhw_time_program_standard_values: EntityInfo[int] | None = None
555
574
 
556
575
 
576
+ class HeatingTimeSwitchPrograms(BaseModel):
577
+ """Heating time switch programs for a specific heating circuit (READ).
578
+
579
+ The daily time programs (Monday-Sunday) use BSB-LAN dataType 9
580
+ (TIMEPROG) and return schedule strings like
581
+ ``"13:00-15:00 ##:##-##:## ##:##-##:##"`` where ``##:##`` marks
582
+ unused time slots.
583
+
584
+ ``standard_values`` is a YESNO enum (0=No, 1=Yes) that resets
585
+ all daily schedules back to the controller's factory defaults.
586
+
587
+ """
588
+
589
+ monday: EntityInfo[str | int] | None = None
590
+ tuesday: EntityInfo[str | int] | None = None
591
+ wednesday: EntityInfo[str | int] | None = None
592
+ thursday: EntityInfo[str | int] | None = None
593
+ friday: EntityInfo[str | int] | None = None
594
+ saturday: EntityInfo[str | int] | None = None
595
+ sunday: EntityInfo[str | int] | None = None
596
+ standard_values: EntityInfo[int] | None = None
597
+
598
+
557
599
  class DeviceTime(BaseModel):
558
600
  """Object holds device time information."""
559
601
 
@@ -0,0 +1,145 @@
1
+ """Tests for heating_schedule method."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+ from unittest.mock import AsyncMock
7
+
8
+ import pytest
9
+
10
+ from bsblan.exceptions import BSBLANError
11
+ from bsblan.models import HeatingTimeSwitchPrograms
12
+
13
+ if TYPE_CHECKING:
14
+ from bsblan import BSBLAN
15
+
16
+
17
+ def _build_schedule_fixture(param_ids: list[str]) -> dict[str, dict[str, Any]]:
18
+ """Build fixture data for schedule parameter IDs."""
19
+ fixture: dict[str, dict[str, Any]] = {}
20
+ for param_id in param_ids:
21
+ if param_id in {"516", "536"}:
22
+ fixture[param_id] = {
23
+ "name": "Standard values",
24
+ "value": "0",
25
+ "unit": "",
26
+ "desc": "No",
27
+ "dataType": 1,
28
+ }
29
+ else:
30
+ fixture[param_id] = {
31
+ "name": f"Time switch {param_id}",
32
+ "value": "06:00-22:00 ##:##-##:## ##:##-##:##",
33
+ "unit": "",
34
+ "desc": "",
35
+ "dataType": 9,
36
+ }
37
+ return fixture
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_heating_schedule_circuit1(mock_bsblan: BSBLAN) -> None:
42
+ """Test reading heating schedule for circuit 1."""
43
+ fixture_data = _build_schedule_fixture(
44
+ ["501", "502", "503", "504", "505", "506", "507", "516"]
45
+ )
46
+
47
+ def mock_request(**kwargs: Any) -> dict[str, Any]:
48
+ param_string = kwargs.get("params", {}).get("Parameter", "")
49
+ requested_param_ids = param_string.split(",") if param_string else []
50
+ return {
51
+ param_id: fixture_data[param_id]
52
+ for param_id in requested_param_ids
53
+ if param_id in fixture_data
54
+ }
55
+
56
+ request_mock = AsyncMock(side_effect=mock_request)
57
+ mock_bsblan._request = request_mock # type: ignore[method-assign]
58
+
59
+ result = await mock_bsblan.heating_schedule(circuit=1)
60
+
61
+ assert isinstance(result, HeatingTimeSwitchPrograms)
62
+ assert result.monday is not None
63
+ assert result.monday.value == "06:00-22:00 ##:##-##:## ##:##-##:##"
64
+ assert result.standard_values is not None
65
+ assert result.standard_values.value == 0
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_heating_schedule_circuit2(mock_bsblan: BSBLAN) -> None:
70
+ """Test reading heating schedule for circuit 2."""
71
+ fixture_data = _build_schedule_fixture(
72
+ ["521", "522", "523", "524", "525", "526", "527", "536"]
73
+ )
74
+
75
+ def mock_request(**kwargs: Any) -> dict[str, Any]:
76
+ param_string = kwargs.get("params", {}).get("Parameter", "")
77
+ requested_param_ids = param_string.split(",") if param_string else []
78
+ return {
79
+ param_id: fixture_data[param_id]
80
+ for param_id in requested_param_ids
81
+ if param_id in fixture_data
82
+ }
83
+
84
+ request_mock = AsyncMock(side_effect=mock_request)
85
+ mock_bsblan._request = request_mock # type: ignore[method-assign]
86
+
87
+ result = await mock_bsblan.heating_schedule(circuit=2)
88
+
89
+ assert isinstance(result, HeatingTimeSwitchPrograms)
90
+ assert result.monday is not None
91
+ assert result.standard_values is not None
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_heating_schedule_include_filter(mock_bsblan: BSBLAN) -> None:
96
+ """Test include filter for heating schedule."""
97
+ fixture_data = _build_schedule_fixture(["521", "536"])
98
+
99
+ def mock_request(**kwargs: Any) -> dict[str, Any]:
100
+ param_string = kwargs.get("params", {}).get("Parameter", "")
101
+ requested_param_ids = param_string.split(",") if param_string else []
102
+ return {
103
+ param_id: fixture_data[param_id]
104
+ for param_id in requested_param_ids
105
+ if param_id in fixture_data
106
+ }
107
+
108
+ request_mock = AsyncMock(side_effect=mock_request)
109
+ mock_bsblan._request = request_mock # type: ignore[method-assign]
110
+
111
+ result = await mock_bsblan.heating_schedule(
112
+ include=["monday", "standard_values"],
113
+ circuit=2,
114
+ )
115
+
116
+ assert result.monday is not None
117
+ assert result.standard_values is not None
118
+ assert result.tuesday is None
119
+
120
+ call_args = request_mock.call_args
121
+ assert call_args is not None
122
+ assert call_args.kwargs["params"]["Parameter"] == "521,536"
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_heating_schedule_empty_include_raises(mock_bsblan: BSBLAN) -> None:
127
+ """Test empty include list raises error."""
128
+ with pytest.raises(BSBLANError, match="Empty include list"):
129
+ await mock_bsblan.heating_schedule(include=[], circuit=1)
130
+
131
+
132
+ @pytest.mark.asyncio
133
+ async def test_heating_schedule_invalid_include_raises(mock_bsblan: BSBLAN) -> None:
134
+ """Test invalid include parameter raises error."""
135
+ with pytest.raises(BSBLANError, match="None of the requested parameters"):
136
+ await mock_bsblan.heating_schedule(include=["invalid_day"], circuit=1)
137
+
138
+
139
+ @pytest.mark.asyncio
140
+ async def test_heating_schedule_no_params_raises(mock_bsblan: BSBLAN) -> None:
141
+ """Test no schedule params in response raises error."""
142
+ mock_bsblan._request = AsyncMock(return_value={}) # type: ignore[method-assign]
143
+
144
+ with pytest.raises(BSBLANError, match="No heating schedule parameters available"):
145
+ await mock_bsblan.heating_schedule(circuit=1)
@@ -6,7 +6,7 @@ from datetime import time
6
6
 
7
7
  import pytest
8
8
 
9
- from bsblan.models import DaySchedule, DHWSchedule, TimeSlot
9
+ from bsblan.models import DaySchedule, DHWSchedule, HeatingSchedule, TimeSlot
10
10
 
11
11
 
12
12
  class TestTimeSlot:
@@ -227,3 +227,34 @@ class TestDHWSchedule:
227
227
  assert schedule.sunday is not None
228
228
  assert schedule.monday is None
229
229
  assert schedule.has_any_schedule() is True
230
+
231
+
232
+ class TestHeatingSchedule:
233
+ """Test cases for HeatingSchedule dataclass."""
234
+
235
+ def test_empty_heating_schedule(self) -> None:
236
+ """Test creating an empty heating schedule."""
237
+ schedule = HeatingSchedule()
238
+ assert schedule.monday is None
239
+ assert schedule.tuesday is None
240
+ assert schedule.has_any_schedule() is False
241
+
242
+ def test_heating_schedule_has_any_schedule_true(self) -> None:
243
+ """Test has_any_schedule returns True when a day is set."""
244
+ schedule = HeatingSchedule(
245
+ monday=DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])
246
+ )
247
+ assert schedule.has_any_schedule() is True
248
+
249
+ def test_heating_schedule_weekend_only(self) -> None:
250
+ """Test setting only weekend days."""
251
+ weekend = DaySchedule(
252
+ slots=[
253
+ TimeSlot(time(8, 0), time(10, 0)),
254
+ TimeSlot(time(18, 0), time(22, 0)),
255
+ ]
256
+ )
257
+ schedule = HeatingSchedule(saturday=weekend, sunday=weekend)
258
+ assert schedule.saturday is not None
259
+ assert schedule.sunday is not None
260
+ assert schedule.has_any_schedule() is True
@@ -0,0 +1,150 @@
1
+ """Tests for set_heating_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, HeatingSchedule, TimeSlot
17
+
18
+ if TYPE_CHECKING:
19
+ from bsblan import BSBLAN
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_set_heating_schedule_circuit1_single_day(mock_bsblan: BSBLAN) -> None:
24
+ """Test setting heating schedule for circuit 1."""
25
+ schedule = HeatingSchedule(
26
+ monday=DaySchedule(
27
+ slots=[
28
+ TimeSlot(time(6, 0), time(8, 0)),
29
+ TimeSlot(time(17, 0), time(21, 0)),
30
+ ]
31
+ )
32
+ )
33
+
34
+ assert isinstance(mock_bsblan._request, AsyncMock)
35
+ await mock_bsblan.set_heating_schedule(schedule, circuit=1)
36
+
37
+ mock_bsblan._request.assert_awaited_with(
38
+ base_path="/JS",
39
+ data={
40
+ "Parameter": "501",
41
+ "Value": "06:00-08:00 17:00-21:00",
42
+ "Type": "1",
43
+ },
44
+ )
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_set_heating_schedule_circuit2_single_day(mock_bsblan: BSBLAN) -> None:
49
+ """Test setting heating schedule for circuit 2."""
50
+ schedule = HeatingSchedule(
51
+ monday=DaySchedule(
52
+ slots=[
53
+ TimeSlot(time(6, 0), time(8, 0)),
54
+ ]
55
+ )
56
+ )
57
+
58
+ assert isinstance(mock_bsblan._request, AsyncMock)
59
+ await mock_bsblan.set_heating_schedule(schedule, circuit=2)
60
+
61
+ mock_bsblan._request.assert_awaited_with(
62
+ base_path="/JS",
63
+ data={
64
+ "Parameter": "521",
65
+ "Value": "06:00-08:00",
66
+ "Type": "1",
67
+ },
68
+ )
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_set_heating_schedule_multiple_days(mock_bsblan: BSBLAN) -> None:
73
+ """Test setting heating schedule for multiple days."""
74
+ schedule = HeatingSchedule(
75
+ monday=DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))]),
76
+ friday=DaySchedule(slots=[TimeSlot(time(7, 0), time(9, 0))]),
77
+ )
78
+
79
+ assert isinstance(mock_bsblan._request, AsyncMock)
80
+ await mock_bsblan.set_heating_schedule(schedule, circuit=1)
81
+
82
+ assert mock_bsblan._request.await_count == 2
83
+
84
+
85
+ @pytest.mark.asyncio
86
+ async def test_set_heating_schedule_empty_raises_error(mock_bsblan: BSBLAN) -> None:
87
+ """Test that empty schedule raises BSBLANError."""
88
+ schedule = HeatingSchedule()
89
+
90
+ with pytest.raises(BSBLANError, match="No schedule provided"):
91
+ await mock_bsblan.set_heating_schedule(schedule, circuit=1)
92
+
93
+
94
+ @pytest.mark.asyncio
95
+ async def test_set_heating_schedule_parameter_ids_per_circuit(
96
+ mock_bsblan: BSBLAN,
97
+ ) -> None:
98
+ """Test that correct parameter IDs are used per day and circuit."""
99
+ expected_params = {
100
+ 1: {
101
+ "monday": "501",
102
+ "tuesday": "502",
103
+ "wednesday": "503",
104
+ "thursday": "504",
105
+ "friday": "505",
106
+ "saturday": "506",
107
+ "sunday": "507",
108
+ },
109
+ 2: {
110
+ "monday": "521",
111
+ "tuesday": "522",
112
+ "wednesday": "523",
113
+ "thursday": "524",
114
+ "friday": "525",
115
+ "saturday": "526",
116
+ "sunday": "527",
117
+ },
118
+ }
119
+
120
+ for circuit, day_map in expected_params.items():
121
+ for day_name, param_id in day_map.items():
122
+ assert isinstance(mock_bsblan._request, AsyncMock)
123
+ mock_bsblan._request.reset_mock()
124
+
125
+ schedule = HeatingSchedule(
126
+ **{day_name: DaySchedule(slots=[TimeSlot(time(6, 0), time(8, 0))])}
127
+ )
128
+ await mock_bsblan.set_heating_schedule(schedule, circuit=circuit)
129
+
130
+ call_args = mock_bsblan._request.call_args
131
+ assert call_args is not None
132
+ assert call_args.kwargs["data"]["Parameter"] == param_id
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_set_heating_schedule_empty_day_schedule(mock_bsblan: BSBLAN) -> None:
137
+ """Test setting an empty day schedule (clears the schedule)."""
138
+ schedule = HeatingSchedule(monday=DaySchedule(slots=[]))
139
+
140
+ assert isinstance(mock_bsblan._request, AsyncMock)
141
+ await mock_bsblan.set_heating_schedule(schedule, circuit=1)
142
+
143
+ mock_bsblan._request.assert_awaited_with(
144
+ base_path="/JS",
145
+ data={
146
+ "Parameter": "501",
147
+ "Value": "",
148
+ "Type": "1",
149
+ },
150
+ )
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