python-bsblan 4.1.0b0__tar.gz → 4.1.0b1__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-4.1.0b0 → python_bsblan-4.1.0b1}/PKG-INFO +1 -1
- python_bsblan-4.1.0b1/examples/speed_test.py +382 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/pyproject.toml +1 -1
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/bsblan.py +20 -6
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/utility.py +13 -2
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_api_validation.py +13 -4
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_include_parameter.py +80 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_info.py +3 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.editorconfig +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.gitattributes +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/CODE_OF_CONDUCT.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/CONTRIBUTING.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/copilot-instructions.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/labels.yml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/prompts/add-parameter.prompt.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/prompts/code-review.prompt.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/release-drafter.yml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/renovate.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/skills/bsblan-parameters/SKILL.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/skills/bsblan-testing/SKILL.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/auto-approve-renovate.yml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/codeql.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/labels.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/linting.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/lock.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/pr-labels.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/release-drafter.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/release.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/stale.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/tests.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/workflows/typing.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.gitignore +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.nvmrc +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.pre-commit-config.yaml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.prettierignore +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.yamllint +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/AGENTS.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/CLAUDE.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/LICENSE.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/README.md +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/examples/control.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/examples/discovery.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/examples/profile_init.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/examples/ruff.toml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/package-lock.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/package.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/sonar-project.properties +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/__init__.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/constants.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/exceptions.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/models.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/src/bsblan/py.typed +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/__init__.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/conftest.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/device.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/dict_version.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/hot_water_state.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/info.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/password.txt +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/sensor.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/state.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/static_state.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/thermostat_hvac.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/thermostat_temp.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/fixtures/time.json +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/ruff.toml +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_api_initialization.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_auth.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_backoff_retry.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_bsblan.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_bsblan_edge_cases.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_configuration.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_constants.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_context_manager.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_device.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_dhw_time_switch.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_entity_info.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_hot_water_additional.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_hotwater_state.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_initialization.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_read_parameters.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_reset_validation.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_schedule_models.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_sensor.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_set_hot_water_schedule.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_set_hotwater.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_state.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_static_state.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_temperature_unit.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_temperature_validation.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_thermostat.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_time.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_utility.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_utility_additional.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_utility_edge_cases.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/tests/test_version_errors.py +0 -0
- {python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-bsblan
|
|
3
|
-
Version: 4.1.
|
|
3
|
+
Version: 4.1.0b1
|
|
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
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Test speed comparison for BSB-LAN API calls.
|
|
2
|
+
|
|
3
|
+
Compares different approaches:
|
|
4
|
+
1. Multiple parallel calls (current approach)
|
|
5
|
+
2. Combined read_parameters call
|
|
6
|
+
3. With/without parameter filtering
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# Set environment variables (optional - will use mDNS discovery if not set)
|
|
10
|
+
export BSBLAN_HOST=10.0.2.60
|
|
11
|
+
export BSBLAN_PORT=80
|
|
12
|
+
export BSBLAN_PASSKEY=your_passkey # if needed
|
|
13
|
+
|
|
14
|
+
# Run the test
|
|
15
|
+
python examples/speed_test.py
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import statistics
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
|
|
26
|
+
from bsblan import BSBLAN, BSBLANConfig
|
|
27
|
+
from discovery import get_bsblan_host, get_config_from_env
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
# Test configuration
|
|
33
|
+
NUM_WARMUP_RUNS = 2
|
|
34
|
+
NUM_TEST_RUNS = 10
|
|
35
|
+
|
|
36
|
+
# Parameters used in different tests
|
|
37
|
+
INFO_PARAMS = ["6224"] # Current firmware version
|
|
38
|
+
STATIC_PARAMS = ["714", "716"] # Min/max temp setpoints
|
|
39
|
+
ALL_PARAMS = INFO_PARAMS + STATIC_PARAMS
|
|
40
|
+
|
|
41
|
+
# Large parameter sets for scalability testing
|
|
42
|
+
# Heating parameters
|
|
43
|
+
HEATING_PARAMS = ["700", "710", "900", "8000", "8740", "8749"]
|
|
44
|
+
|
|
45
|
+
# Sensor parameters
|
|
46
|
+
SENSOR_PARAMS = ["8700", "8740"]
|
|
47
|
+
|
|
48
|
+
# Hot water parameters (a good mix of config and state)
|
|
49
|
+
HOT_WATER_PARAMS = [
|
|
50
|
+
"1600", # operating_mode
|
|
51
|
+
"1601", # eco_mode_selection
|
|
52
|
+
"1610", # nominal_setpoint
|
|
53
|
+
"1614", # nominal_setpoint_max
|
|
54
|
+
"1612", # reduced_setpoint
|
|
55
|
+
"1620", # release
|
|
56
|
+
"1630", # dhw_charging_priority
|
|
57
|
+
"1640", # legionella_function
|
|
58
|
+
"1641", # legionella_function_periodicity
|
|
59
|
+
"1642", # legionella_function_day
|
|
60
|
+
"1644", # legionella_function_time
|
|
61
|
+
"1645", # legionella_function_setpoint
|
|
62
|
+
"8830", # dhw_actual_value_top_temperature
|
|
63
|
+
"8820", # state_dhw_pump
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Combined large set (~20 params)
|
|
67
|
+
LARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS[:8] # ~16 params
|
|
68
|
+
|
|
69
|
+
# Extra large set (~22 params)
|
|
70
|
+
XLARGE_PARAM_SET = HEATING_PARAMS + SENSOR_PARAMS + HOT_WATER_PARAMS # ~22 params
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class BenchmarkResult:
|
|
75
|
+
"""Results from a speed benchmark."""
|
|
76
|
+
|
|
77
|
+
name: str
|
|
78
|
+
times: list[float]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def avg(self) -> float:
|
|
82
|
+
"""Average time."""
|
|
83
|
+
return statistics.mean(self.times)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def min(self) -> float:
|
|
87
|
+
"""Minimum time."""
|
|
88
|
+
return min(self.times)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def max(self) -> float:
|
|
92
|
+
"""Maximum time."""
|
|
93
|
+
return max(self.times)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def median(self) -> float:
|
|
97
|
+
"""Median time."""
|
|
98
|
+
return statistics.median(self.times)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def stdev(self) -> float:
|
|
102
|
+
"""Standard deviation."""
|
|
103
|
+
return statistics.stdev(self.times) if len(self.times) > 1 else 0.0
|
|
104
|
+
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
"""Format results as string."""
|
|
107
|
+
return (
|
|
108
|
+
f"{self.name}:\n"
|
|
109
|
+
f" avg={self.avg:.3f}s, median={self.median:.3f}s, "
|
|
110
|
+
f"min={self.min:.3f}s, max={self.max:.3f}s, stdev={self.stdev:.3f}s"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def run_test(
|
|
115
|
+
name: str,
|
|
116
|
+
test_fn: Callable[[], Awaitable[object]],
|
|
117
|
+
num_runs: int = NUM_TEST_RUNS,
|
|
118
|
+
warmup_runs: int = NUM_WARMUP_RUNS,
|
|
119
|
+
) -> BenchmarkResult:
|
|
120
|
+
"""Run a test function multiple times and collect timing stats.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
name: Test name for display.
|
|
124
|
+
test_fn: Async function to test.
|
|
125
|
+
num_runs: Number of timed test runs.
|
|
126
|
+
warmup_runs: Number of warmup runs (not timed).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
BenchmarkResult with timing statistics.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
# Warmup runs (not counted)
|
|
133
|
+
for _ in range(warmup_runs):
|
|
134
|
+
await test_fn()
|
|
135
|
+
|
|
136
|
+
# Timed runs
|
|
137
|
+
times: list[float] = []
|
|
138
|
+
for i in range(num_runs):
|
|
139
|
+
start = time.perf_counter()
|
|
140
|
+
await test_fn()
|
|
141
|
+
elapsed = time.perf_counter() - start
|
|
142
|
+
times.append(elapsed)
|
|
143
|
+
print(f" Run {i + 1}/{num_runs}: {elapsed:.3f}s")
|
|
144
|
+
|
|
145
|
+
return BenchmarkResult(name=name, times=times)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def bench_3_parallel_calls(bsblan: BSBLAN) -> None:
|
|
149
|
+
"""Benchmark 3 parallel calls (current initialization approach)."""
|
|
150
|
+
await asyncio.gather(
|
|
151
|
+
bsblan.device(),
|
|
152
|
+
bsblan.info(),
|
|
153
|
+
bsblan.static_values(),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def bench_2_parallel_calls(bsblan: BSBLAN) -> None:
|
|
158
|
+
"""Benchmark 2 calls: device + combined read_parameters."""
|
|
159
|
+
await asyncio.gather(
|
|
160
|
+
bsblan.device(),
|
|
161
|
+
bsblan.read_parameters(ALL_PARAMS),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def bench_1_read_params(bsblan: BSBLAN) -> None:
|
|
166
|
+
"""Benchmark single read_parameters call with all params."""
|
|
167
|
+
await bsblan.read_parameters(ALL_PARAMS)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def bench_static_values_filtered(bsblan: BSBLAN) -> None:
|
|
171
|
+
"""Benchmark static_values with include filter."""
|
|
172
|
+
await bsblan.static_values(include=["min_temp"])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def bench_info_filtered(bsblan: BSBLAN) -> None:
|
|
176
|
+
"""Benchmark info() with include filter."""
|
|
177
|
+
await bsblan.info(include=["device_identification"])
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def bench_large_params_single_call(bsblan: BSBLAN) -> None:
|
|
181
|
+
"""Benchmark single call with ~16 parameters."""
|
|
182
|
+
await bsblan.read_parameters(LARGE_PARAM_SET)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def bench_xlarge_params_single_call(bsblan: BSBLAN) -> None:
|
|
186
|
+
"""Benchmark single call with ~22 parameters."""
|
|
187
|
+
await bsblan.read_parameters(XLARGE_PARAM_SET)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def bench_large_params_4_parallel_calls(bsblan: BSBLAN) -> None:
|
|
191
|
+
"""Benchmark 4 parallel calls, each with ~4 parameters."""
|
|
192
|
+
chunk_size = len(LARGE_PARAM_SET) // 4
|
|
193
|
+
chunks = [
|
|
194
|
+
LARGE_PARAM_SET[i : i + chunk_size]
|
|
195
|
+
for i in range(0, len(LARGE_PARAM_SET), chunk_size)
|
|
196
|
+
]
|
|
197
|
+
await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def bench_xlarge_params_4_parallel_calls(bsblan: BSBLAN) -> None:
|
|
201
|
+
"""Benchmark 4 parallel calls splitting ~22 parameters."""
|
|
202
|
+
chunk_size = len(XLARGE_PARAM_SET) // 4
|
|
203
|
+
chunks = [
|
|
204
|
+
XLARGE_PARAM_SET[i : i + chunk_size]
|
|
205
|
+
for i in range(0, len(XLARGE_PARAM_SET), chunk_size)
|
|
206
|
+
]
|
|
207
|
+
await asyncio.gather(*[bsblan.read_parameters(chunk) for chunk in chunks])
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def bench_xlarge_params_2_parallel_calls(bsblan: BSBLAN) -> None:
|
|
211
|
+
"""Benchmark 2 parallel calls splitting ~22 parameters."""
|
|
212
|
+
mid = len(XLARGE_PARAM_SET) // 2
|
|
213
|
+
await asyncio.gather(
|
|
214
|
+
bsblan.read_parameters(XLARGE_PARAM_SET[:mid]),
|
|
215
|
+
bsblan.read_parameters(XLARGE_PARAM_SET[mid:]),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
async def bench_info_only(bsblan: BSBLAN) -> None:
|
|
220
|
+
"""Benchmark info() call only."""
|
|
221
|
+
await bsblan.info()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def bench_static_values_only(bsblan: BSBLAN) -> None:
|
|
225
|
+
"""Benchmark static_values() call only."""
|
|
226
|
+
await bsblan.static_values()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def run_all_benchmarks(bsblan: BSBLAN) -> list[BenchmarkResult]:
|
|
230
|
+
"""Run all speed benchmarks and return results."""
|
|
231
|
+
results: list[BenchmarkResult] = []
|
|
232
|
+
|
|
233
|
+
# Define basic tests
|
|
234
|
+
basic_tests = [
|
|
235
|
+
(
|
|
236
|
+
"Test 1: 3 parallel calls (device + info + static_values)",
|
|
237
|
+
"3 parallel calls",
|
|
238
|
+
lambda: bench_3_parallel_calls(bsblan),
|
|
239
|
+
),
|
|
240
|
+
(
|
|
241
|
+
"Test 2: 2 parallel calls (device + read_parameters)",
|
|
242
|
+
"2 parallel calls",
|
|
243
|
+
lambda: bench_2_parallel_calls(bsblan),
|
|
244
|
+
),
|
|
245
|
+
(
|
|
246
|
+
"Test 3: Single read_parameters call",
|
|
247
|
+
"1 read_parameters",
|
|
248
|
+
lambda: bench_1_read_params(bsblan),
|
|
249
|
+
),
|
|
250
|
+
(
|
|
251
|
+
"Test 4: static_values with include filter (min_temp only)",
|
|
252
|
+
"static_values (filtered)",
|
|
253
|
+
lambda: bench_static_values_filtered(bsblan),
|
|
254
|
+
),
|
|
255
|
+
(
|
|
256
|
+
"Test 5: info with include filter (device_identification only)",
|
|
257
|
+
"info (filtered)",
|
|
258
|
+
lambda: bench_info_filtered(bsblan),
|
|
259
|
+
),
|
|
260
|
+
(
|
|
261
|
+
"Test 6: static_values without filter (all params)",
|
|
262
|
+
"static_values (all)",
|
|
263
|
+
lambda: bench_static_values_only(bsblan),
|
|
264
|
+
),
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
for desc, name, bench_fn in basic_tests:
|
|
268
|
+
print(f"\n{desc}")
|
|
269
|
+
result = await run_test(name, bench_fn)
|
|
270
|
+
results.append(result)
|
|
271
|
+
|
|
272
|
+
# Scalability tests
|
|
273
|
+
print("\n" + "=" * 60)
|
|
274
|
+
print("SCALABILITY TESTS - Many Parameters")
|
|
275
|
+
print("=" * 60)
|
|
276
|
+
|
|
277
|
+
scalability_tests = [
|
|
278
|
+
(
|
|
279
|
+
f"Test 7: Single call with {len(LARGE_PARAM_SET)} params",
|
|
280
|
+
f"1 call ({len(LARGE_PARAM_SET)} params)",
|
|
281
|
+
lambda: bench_large_params_single_call(bsblan),
|
|
282
|
+
),
|
|
283
|
+
(
|
|
284
|
+
f"Test 8: 4 parallel calls ({len(LARGE_PARAM_SET)} params split)",
|
|
285
|
+
f"4 calls ({len(LARGE_PARAM_SET)} params)",
|
|
286
|
+
lambda: bench_large_params_4_parallel_calls(bsblan),
|
|
287
|
+
),
|
|
288
|
+
(
|
|
289
|
+
f"Test 9: Single call with {len(XLARGE_PARAM_SET)} params",
|
|
290
|
+
f"1 call ({len(XLARGE_PARAM_SET)} params)",
|
|
291
|
+
lambda: bench_xlarge_params_single_call(bsblan),
|
|
292
|
+
),
|
|
293
|
+
(
|
|
294
|
+
f"Test 10: 2 parallel calls ({len(XLARGE_PARAM_SET)} params split)",
|
|
295
|
+
f"2 calls ({len(XLARGE_PARAM_SET)} params)",
|
|
296
|
+
lambda: bench_xlarge_params_2_parallel_calls(bsblan),
|
|
297
|
+
),
|
|
298
|
+
(
|
|
299
|
+
f"Test 11: 4 parallel calls ({len(XLARGE_PARAM_SET)} params split)",
|
|
300
|
+
f"4 calls ({len(XLARGE_PARAM_SET)} params)",
|
|
301
|
+
lambda: bench_xlarge_params_4_parallel_calls(bsblan),
|
|
302
|
+
),
|
|
303
|
+
]
|
|
304
|
+
|
|
305
|
+
for desc, name, bench_fn in scalability_tests:
|
|
306
|
+
print(f"\n{desc}")
|
|
307
|
+
result = await run_test(name, bench_fn)
|
|
308
|
+
results.append(result)
|
|
309
|
+
|
|
310
|
+
return results
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def print_results(results: list[BenchmarkResult]) -> None:
|
|
314
|
+
"""Print test results summary."""
|
|
315
|
+
print("\n" + "=" * 60)
|
|
316
|
+
print("RESULTS SUMMARY")
|
|
317
|
+
print("=" * 60)
|
|
318
|
+
|
|
319
|
+
for r in results:
|
|
320
|
+
print(f"\n{r}")
|
|
321
|
+
|
|
322
|
+
# Compare approaches
|
|
323
|
+
baseline = results[0].avg # 3 parallel calls
|
|
324
|
+
print("\n" + "-" * 60)
|
|
325
|
+
print("COMPARISON (vs 3 parallel calls baseline)")
|
|
326
|
+
print("-" * 60)
|
|
327
|
+
|
|
328
|
+
for r in results[1:]:
|
|
329
|
+
diff = baseline - r.avg
|
|
330
|
+
pct = (diff / baseline) * 100 if baseline > 0 else 0
|
|
331
|
+
faster_slower = "faster" if diff > 0 else "slower"
|
|
332
|
+
print(f"{r.name}: {abs(diff):.3f}s {faster_slower} ({abs(pct):.1f}%)")
|
|
333
|
+
|
|
334
|
+
# Best approach
|
|
335
|
+
best = min(results, key=lambda x: x.avg)
|
|
336
|
+
print(f"\n✓ Best approach: {best.name} (avg: {best.avg:.3f}s)")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
async def main() -> None:
|
|
340
|
+
"""Run speed comparison tests."""
|
|
341
|
+
print("=" * 60)
|
|
342
|
+
print("BSB-LAN API Speed Comparison Test")
|
|
343
|
+
print("=" * 60)
|
|
344
|
+
|
|
345
|
+
# Get configuration from environment or discovery
|
|
346
|
+
env_config = get_config_from_env()
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
host, port = await get_bsblan_host(discovery_seconds=3.0)
|
|
350
|
+
except RuntimeError:
|
|
351
|
+
print("\nNo BSB-LAN device found!")
|
|
352
|
+
print("Set BSBLAN_HOST environment variable or enable mDNS on your device.")
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
print(f"\nConnecting to: {host}:{port}")
|
|
356
|
+
|
|
357
|
+
# Build config - cast values to str for type safety
|
|
358
|
+
passkey = env_config.get("passkey")
|
|
359
|
+
username = env_config.get("username")
|
|
360
|
+
password = env_config.get("password")
|
|
361
|
+
|
|
362
|
+
config = BSBLANConfig(
|
|
363
|
+
host=host,
|
|
364
|
+
port=port,
|
|
365
|
+
passkey=str(passkey) if passkey else None,
|
|
366
|
+
username=str(username) if username else None,
|
|
367
|
+
password=str(password) if password else None,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
async with BSBLAN(config) as bsblan:
|
|
371
|
+
await bsblan.initialize()
|
|
372
|
+
print("✓ BSB-LAN client initialized\n")
|
|
373
|
+
|
|
374
|
+
print(f"Running {NUM_TEST_RUNS} test runs with {NUM_WARMUP_RUNS} warmup runs\n")
|
|
375
|
+
print("-" * 60)
|
|
376
|
+
|
|
377
|
+
results = await run_all_benchmarks(bsblan)
|
|
378
|
+
print_results(results)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
if __name__ == "__main__":
|
|
382
|
+
asyncio.run(main())
|
|
@@ -171,7 +171,9 @@ class BSBLAN:
|
|
|
171
171
|
# Initialize the API validator (but don't validate sections yet)
|
|
172
172
|
self._api_validator = APIValidator(self._api_data)
|
|
173
173
|
|
|
174
|
-
async def _ensure_section_validated(
|
|
174
|
+
async def _ensure_section_validated(
|
|
175
|
+
self, section: SectionLiteral, include: list[str] | None = None
|
|
176
|
+
) -> None:
|
|
175
177
|
"""Ensure a section is validated before use (lazy loading).
|
|
176
178
|
|
|
177
179
|
This method validates a section on-demand when it's first accessed.
|
|
@@ -182,6 +184,8 @@ class BSBLAN:
|
|
|
182
184
|
|
|
183
185
|
Args:
|
|
184
186
|
section: The section name to validate
|
|
187
|
+
include: Optional list of parameter names to validate. If None,
|
|
188
|
+
validates all parameters for the section.
|
|
185
189
|
|
|
186
190
|
"""
|
|
187
191
|
if not self._api_validator:
|
|
@@ -201,7 +205,7 @@ class BSBLAN:
|
|
|
201
205
|
return
|
|
202
206
|
|
|
203
207
|
logger.debug("Lazy loading section: %s", section)
|
|
204
|
-
response_data = await self._validate_api_section(section)
|
|
208
|
+
response_data = await self._validate_api_section(section, include)
|
|
205
209
|
|
|
206
210
|
# Extract temperature unit from heating section validation
|
|
207
211
|
# (parameter 710 - target_temperature is always in heating section)
|
|
@@ -337,12 +341,14 @@ class BSBLAN:
|
|
|
337
341
|
self._extract_temperature_unit_from_response(response_data)
|
|
338
342
|
|
|
339
343
|
async def _validate_api_section(
|
|
340
|
-
self, section: SectionLiteral
|
|
344
|
+
self, section: SectionLiteral, include: list[str] | None = None
|
|
341
345
|
) -> dict[str, Any] | None:
|
|
342
346
|
"""Validate a specific section of the API configuration.
|
|
343
347
|
|
|
344
348
|
Args:
|
|
345
349
|
section: The section name to validate
|
|
350
|
+
include: Optional list of parameter names to validate. If None,
|
|
351
|
+
validates all parameters for the section.
|
|
346
352
|
|
|
347
353
|
Returns:
|
|
348
354
|
dict[str, Any] | None: The response data from the device, or None if
|
|
@@ -371,6 +377,14 @@ class BSBLAN:
|
|
|
371
377
|
error_msg = f"Section '{section}' not found in API data"
|
|
372
378
|
raise BSBLANError(error_msg) from err
|
|
373
379
|
|
|
380
|
+
# Filter to only included params if specified
|
|
381
|
+
if include is not None:
|
|
382
|
+
section_data = {
|
|
383
|
+
param_id: name
|
|
384
|
+
for param_id, name in section_data.items()
|
|
385
|
+
if name in include
|
|
386
|
+
}
|
|
387
|
+
|
|
374
388
|
try:
|
|
375
389
|
# Request data from device for validation
|
|
376
390
|
params = await self._extract_params_summary(section_data)
|
|
@@ -379,7 +393,7 @@ class BSBLAN:
|
|
|
379
393
|
)
|
|
380
394
|
|
|
381
395
|
# Validate the section against actual device response
|
|
382
|
-
api_validator.validate_section(section, response_data)
|
|
396
|
+
api_validator.validate_section(section, response_data, include)
|
|
383
397
|
# Update API data with validated configuration
|
|
384
398
|
if self._api_data:
|
|
385
399
|
self._api_data[section] = api_validator.get_section_params(section)
|
|
@@ -794,8 +808,8 @@ class BSBLAN:
|
|
|
794
808
|
are valid for this section.
|
|
795
809
|
|
|
796
810
|
"""
|
|
797
|
-
# Lazy load: validate section on first access
|
|
798
|
-
await self._ensure_section_validated(section)
|
|
811
|
+
# Lazy load: validate section on first access (only for included params)
|
|
812
|
+
await self._ensure_section_validated(section, include)
|
|
799
813
|
|
|
800
814
|
section_params = self._api_validator.get_section_params(section)
|
|
801
815
|
|
|
@@ -77,13 +77,20 @@ class APIValidator:
|
|
|
77
77
|
api_config: Any # intentionally permissive to support tests and dynamic data
|
|
78
78
|
validated_sections: set[str] = field(default_factory=set)
|
|
79
79
|
|
|
80
|
-
def validate_section(
|
|
80
|
+
def validate_section(
|
|
81
|
+
self,
|
|
82
|
+
section: str,
|
|
83
|
+
request_data: dict[str, Any],
|
|
84
|
+
include: list[str] | None = None,
|
|
85
|
+
) -> None:
|
|
81
86
|
"""Validate and update a section of API config based on actual device support.
|
|
82
87
|
|
|
83
88
|
Args:
|
|
84
89
|
section: The section of the API config to validate
|
|
85
90
|
(e.g., 'heating', 'hot_water')
|
|
86
91
|
request_data: Response data from the device for validation
|
|
92
|
+
include: Optional list of parameter names to validate. If None,
|
|
93
|
+
validates all parameters for the section.
|
|
87
94
|
|
|
88
95
|
"""
|
|
89
96
|
# Check if the section exists in the APIConfig object
|
|
@@ -99,8 +106,12 @@ class APIValidator:
|
|
|
99
106
|
section_config = self.api_config[section]
|
|
100
107
|
params_to_remove = []
|
|
101
108
|
|
|
102
|
-
# Check each parameter in the section
|
|
109
|
+
# Check each parameter in the section (filtered by include if specified)
|
|
103
110
|
for param_id, param_name in section_config.items():
|
|
111
|
+
# Skip params not in include list (if include is specified)
|
|
112
|
+
if include is not None and param_name not in include:
|
|
113
|
+
continue
|
|
114
|
+
|
|
104
115
|
if param_id not in request_data:
|
|
105
116
|
logger.info(
|
|
106
117
|
"Parameter %s (%s) not found in device response",
|
|
@@ -163,7 +163,10 @@ async def test_validate_api_section_validation_error(
|
|
|
163
163
|
bsblan._api_validator = APIValidator(bsblan._api_data)
|
|
164
164
|
|
|
165
165
|
def mock_validate(
|
|
166
|
-
_self: APIValidator,
|
|
166
|
+
_self: APIValidator,
|
|
167
|
+
_section: str,
|
|
168
|
+
_response: dict[str, Any],
|
|
169
|
+
_include: list[str] | None = None,
|
|
167
170
|
) -> NoReturn:
|
|
168
171
|
error_message = "Validation error"
|
|
169
172
|
raise BSBLANError(error_message)
|
|
@@ -296,7 +299,9 @@ async def test_ensure_section_validated_double_check_after_lock() -> None:
|
|
|
296
299
|
# Track validation calls
|
|
297
300
|
validation_count = 0
|
|
298
301
|
|
|
299
|
-
async def mock_validate(
|
|
302
|
+
async def mock_validate(
|
|
303
|
+
section: str, _include: list[str] | None = None
|
|
304
|
+
) -> dict[str, Any]:
|
|
300
305
|
nonlocal validation_count
|
|
301
306
|
validation_count += 1
|
|
302
307
|
# Mark section as validated
|
|
@@ -329,7 +334,9 @@ async def test_ensure_section_validated_concurrent_double_check() -> None:
|
|
|
329
334
|
validation_count = 0
|
|
330
335
|
validation_started = asyncio.Event()
|
|
331
336
|
|
|
332
|
-
async def slow_validate(
|
|
337
|
+
async def slow_validate(
|
|
338
|
+
section: str, _include: list[str] | None = None
|
|
339
|
+
) -> dict[str, Any]:
|
|
333
340
|
nonlocal validation_count
|
|
334
341
|
validation_count += 1
|
|
335
342
|
validation_started.set()
|
|
@@ -392,7 +399,9 @@ async def test_ensure_section_validated_heating_extracts_temp_unit() -> None:
|
|
|
392
399
|
bsblan._api_validator = APIValidator(bsblan._api_data)
|
|
393
400
|
|
|
394
401
|
# Mock _validate_api_section to return response with temp unit
|
|
395
|
-
async def mock_validate(
|
|
402
|
+
async def mock_validate(
|
|
403
|
+
section: str, _include: list[str] | None = None
|
|
404
|
+
) -> dict[str, Any]:
|
|
396
405
|
bsblan._api_validator.validated_sections.add(section)
|
|
397
406
|
if section == "heating":
|
|
398
407
|
return {"710": {"value": "20.0", "unit": "°F"}}
|
|
@@ -164,6 +164,86 @@ async def test_section_method_with_invalid_params(
|
|
|
164
164
|
request_mock.assert_not_awaited()
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
# ========== Tests for _validate_api_section with include parameter ==========
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.asyncio
|
|
171
|
+
async def test_validate_api_section_with_include_filter(monkeypatch: Any) -> None:
|
|
172
|
+
"""Test _validate_api_section filters params when include is specified."""
|
|
173
|
+
async with aiohttp.ClientSession() as session:
|
|
174
|
+
config = BSBLANConfig(host="example.com")
|
|
175
|
+
bsblan = BSBLAN(config, session=session)
|
|
176
|
+
|
|
177
|
+
monkeypatch.setattr(bsblan, "_firmware_version", "1.0.38-20200730234859")
|
|
178
|
+
monkeypatch.setattr(bsblan, "_api_version", "v3")
|
|
179
|
+
|
|
180
|
+
# Set up API data with multiple params
|
|
181
|
+
api_data = {
|
|
182
|
+
"heating": {
|
|
183
|
+
"700": "hvac_mode",
|
|
184
|
+
"710": "target_temperature",
|
|
185
|
+
"8740": "current_temperature",
|
|
186
|
+
},
|
|
187
|
+
"sensor": {},
|
|
188
|
+
"staticValues": {},
|
|
189
|
+
"device": {},
|
|
190
|
+
"hot_water": {},
|
|
191
|
+
}
|
|
192
|
+
bsblan._api_data = api_data # type: ignore[assignment]
|
|
193
|
+
|
|
194
|
+
api_validator = APIValidator(api_data)
|
|
195
|
+
bsblan._api_validator = api_validator
|
|
196
|
+
|
|
197
|
+
# Mock request to return only the filtered param
|
|
198
|
+
request_mock = AsyncMock(
|
|
199
|
+
return_value={"700": {"value": "1", "unit": "", "desc": "Auto"}}
|
|
200
|
+
)
|
|
201
|
+
monkeypatch.setattr(bsblan, "_request", request_mock)
|
|
202
|
+
|
|
203
|
+
# Validate with include filter - should only request hvac_mode
|
|
204
|
+
result = await bsblan._validate_api_section("heating", include=["hvac_mode"])
|
|
205
|
+
|
|
206
|
+
# Verify only filtered param was requested
|
|
207
|
+
request_mock.assert_awaited_once()
|
|
208
|
+
call_args = request_mock.call_args
|
|
209
|
+
assert call_args.kwargs["params"]["Parameter"] == "700"
|
|
210
|
+
|
|
211
|
+
# Result should contain the response data
|
|
212
|
+
assert result is not None
|
|
213
|
+
assert "700" in result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
async def test_validate_section_skips_params_not_in_include() -> None:
|
|
218
|
+
"""Test validate_section skips params not in include list."""
|
|
219
|
+
# Set up API config with multiple params
|
|
220
|
+
api_config = {
|
|
221
|
+
"heating": {
|
|
222
|
+
"700": "hvac_mode",
|
|
223
|
+
"710": "target_temperature",
|
|
224
|
+
"8740": "current_temperature",
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
api_validator = APIValidator(api_config)
|
|
229
|
+
|
|
230
|
+
# Mock request data with only one param (simulating filtered response)
|
|
231
|
+
request_data = {"700": {"value": "1", "unit": "", "desc": "Auto"}}
|
|
232
|
+
|
|
233
|
+
# Validate with include filter - should skip params not in include
|
|
234
|
+
api_validator.validate_section("heating", request_data, include=["hvac_mode"])
|
|
235
|
+
|
|
236
|
+
# Section should be validated
|
|
237
|
+
assert api_validator.is_section_validated("heating")
|
|
238
|
+
|
|
239
|
+
# hvac_mode (700) should still be in config since it was valid
|
|
240
|
+
# Other params should NOT be removed since they weren't in include
|
|
241
|
+
assert "700" in api_validator.api_config["heating"]
|
|
242
|
+
# 710 and 8740 were not validated (skipped), so they remain
|
|
243
|
+
assert "710" in api_validator.api_config["heating"]
|
|
244
|
+
assert "8740" in api_validator.api_config["heating"]
|
|
245
|
+
|
|
246
|
+
|
|
167
247
|
# ========== Additional state() specific tests ==========
|
|
168
248
|
|
|
169
249
|
|
|
@@ -46,6 +46,9 @@ async def test_info(aresponses: ResponsesMockServer, monkeypatch: Any) -> None:
|
|
|
46
46
|
|
|
47
47
|
info: Info = await bsblan.info()
|
|
48
48
|
assert info
|
|
49
|
+
assert info.controller_family is not None
|
|
50
|
+
assert info.controller_variant is not None
|
|
51
|
+
assert info.device_identification is not None
|
|
49
52
|
assert info.controller_family.value == 211
|
|
50
53
|
assert info.controller_variant.value == 127
|
|
51
54
|
assert info.device_identification.value == "RVS21.831F/127"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_bsblan-4.1.0b0 → python_bsblan-4.1.0b1}/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md
RENAMED
|
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
|