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.
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/PKG-INFO +1 -1
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/client.md +2 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/models.md +8 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/pyproject.toml +1 -1
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/__init__.py +4 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/bsblan.py +88 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/constants.py +28 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/models.py +58 -16
- python_bsblan-5.2.0/tests/test_heating_schedule.py +145 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_schedule_models.py +32 -1
- python_bsblan-5.2.0/tests/test_set_heating_schedule.py +150 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.editorconfig +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.gitattributes +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/CODE_OF_CONDUCT.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/CONTRIBUTING.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/SECURITY.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/copilot-instructions.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/labels.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/prompts/add-parameter.prompt.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/prompts/code-review.prompt.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/release-drafter.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/renovate.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/skills/bsblan-parameters/SKILL.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/skills/bsblan-testing/SKILL.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/auto-approve-renovate.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/codeql.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/dependency-review.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/docs.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/labels.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/linting.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/lock.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/pr-labels.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/release-drafter.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/release.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/scorecard.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/stale.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/tests.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/workflows/typing.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.github/zizmor.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.gitignore +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.nvmrc +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.pre-commit-config.yaml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.prettierignore +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/.yamllint +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/AGENTS.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/CLAUDE.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/LICENSE.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/Makefile +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/README.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/constants.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/api/exceptions.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/getting-started.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/docs/index.md +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/control.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/discovery.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/fetch_param.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/profile_init.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/ruff.toml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/examples/speed_test.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/mkdocs.yml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/package-lock.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/package.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/sonar-project.properties +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/exceptions.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/py.typed +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/src/bsblan/utility.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/__init__.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/conftest.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/device.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/dict_version.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/hot_water_state.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/info.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/password.txt +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/sensor.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/state.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/state_circuit2.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/static_state.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/static_state_circuit2.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/thermostat_hvac.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/thermostat_temp.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/fixtures/time.json +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/ruff.toml +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_api_initialization.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_api_validation.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_auth.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_backoff_retry.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_bsblan.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_bsblan_edge_cases.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_circuit.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_configuration.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_constants.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_context_manager.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_device.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_dhw_time_switch.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_entity_info.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_entity_info_ha.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_hot_water_additional.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_hotwater_state.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_include_parameter.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_info.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_initialization.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_read_parameters.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_reset_validation.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_sensor.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_set_hot_water_schedule.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_set_hotwater.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_state.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_static_state.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_temperature_unit.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_temperature_validation.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_thermostat.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_time.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility_additional.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_utility_edge_cases.py +0 -0
- {python_bsblan-5.1.5 → python_bsblan-5.2.0}/tests/test_version_errors.py +0 -0
|
@@ -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
|
|
@@ -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
|
|
145
|
-
"""
|
|
144
|
+
class WeeklySchedule:
|
|
145
|
+
"""Base weekly schedule with optional day schedules.
|
|
146
146
|
|
|
147
|
-
|
|
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
|
|
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
|
|
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
|