violet-poolController-api 0.0.36__tar.gz → 0.0.38__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.
- {violet_poolcontroller_api-0.0.36/violet_poolController_api.egg-info → violet_poolcontroller_api-0.0.38}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/pyproject.toml +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_api.py +83 -2
- violet_poolcontroller_api-0.0.38/tests/test_language_policy.py +136 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_readings.py +31 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_sanitizer.py +18 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38/violet_poolController_api.egg-info}/PKG-INFO +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/SOURCES.txt +1 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/__init__.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_dosing.py +36 -8
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_outputs.py +14 -4
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_system.py +7 -2
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/api.py +16 -12
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/circuit_breaker.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/const_api.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/const_devices.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/parsers.py +1 -1
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/readings.py +2 -2
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/utils_rate_limiter.py +7 -7
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/utils_sanitizer.py +27 -16
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/LICENSE +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/README.md +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/setup.cfg +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_api_smoke.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_circuit_breaker.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_mock_server.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_parsers.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_rate_limiter.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/dependency_links.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/requires.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolController_api.egg-info/top_level.txt +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_mixin.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_model.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/_api_readings.py +0 -0
- {violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/violet_poolcontroller_api/py.typed +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
from collections.abc import Mapping
|
|
24
24
|
from typing import TYPE_CHECKING, Any, Never
|
|
25
|
+
from unittest.mock import AsyncMock
|
|
25
26
|
|
|
26
27
|
import aiohttp
|
|
27
28
|
import pytest
|
|
@@ -29,7 +30,11 @@ import pytest_asyncio
|
|
|
29
30
|
from aioresponses import aioresponses
|
|
30
31
|
from yarl import URL
|
|
31
32
|
|
|
32
|
-
from violet_poolcontroller_api.api import
|
|
33
|
+
from violet_poolcontroller_api.api import (
|
|
34
|
+
VioletPoolAPI,
|
|
35
|
+
VioletPoolAPIError,
|
|
36
|
+
VioletSetpointError,
|
|
37
|
+
)
|
|
33
38
|
from violet_poolcontroller_api.circuit_breaker import CircuitBreakerOpenError
|
|
34
39
|
from violet_poolcontroller_api.const_api import (
|
|
35
40
|
ERROR_CODES,
|
|
@@ -37,6 +42,7 @@ from violet_poolcontroller_api.const_api import (
|
|
|
37
42
|
ERROR_SEVERITY_INFO,
|
|
38
43
|
ERROR_SEVERITY_REMINDER,
|
|
39
44
|
ERROR_SEVERITY_WARNING,
|
|
45
|
+
TARGET_PH,
|
|
40
46
|
)
|
|
41
47
|
|
|
42
48
|
if TYPE_CHECKING:
|
|
@@ -938,6 +944,15 @@ async def test_set_dosing_parameters(
|
|
|
938
944
|
assert result["success"] is True
|
|
939
945
|
|
|
940
946
|
|
|
947
|
+
@pytest.mark.asyncio
|
|
948
|
+
async def test_set_dosing_parameters_enforces_setpoint_range(
|
|
949
|
+
api_client: VioletPoolAPI,
|
|
950
|
+
) -> None:
|
|
951
|
+
"""set_dosing_parameters must not bypass the pH/ORP/chlorine safety bounds."""
|
|
952
|
+
with pytest.raises(VioletSetpointError, match="outside the valid range"):
|
|
953
|
+
await api_client.set_dosing_parameters({TARGET_PH: 20.0})
|
|
954
|
+
|
|
955
|
+
|
|
941
956
|
@pytest.mark.asyncio
|
|
942
957
|
@pytest.mark.parametrize(("raw_value", "expected"), [("1", True), ("1.0", True), ("0.0", False)])
|
|
943
958
|
async def test_is_dosage_enabled_accepts_numeric_strings(
|
|
@@ -1103,6 +1118,38 @@ async def test_get_system_services(
|
|
|
1103
1118
|
}
|
|
1104
1119
|
|
|
1105
1120
|
|
|
1121
|
+
@pytest.mark.asyncio
|
|
1122
|
+
async def test_get_system_services_accepts_decimal_state(
|
|
1123
|
+
mock_aioresponse: aioresponses,
|
|
1124
|
+
api_client: VioletPoolAPI,
|
|
1125
|
+
) -> None:
|
|
1126
|
+
"""The controller may report service state as a decimal string like '1.0'."""
|
|
1127
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1128
|
+
mock_aioresponse.get(
|
|
1129
|
+
url,
|
|
1130
|
+
payload={"proftpd": "1.0", "shairport": "0.0"},
|
|
1131
|
+
status=200,
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
result = await api_client.get_system_services()
|
|
1135
|
+
|
|
1136
|
+
assert result["ftp"] is True
|
|
1137
|
+
assert result["shairport"] is False
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
@pytest.mark.asyncio
|
|
1141
|
+
async def test_get_system_services_rejects_garbage_state(
|
|
1142
|
+
mock_aioresponse: aioresponses,
|
|
1143
|
+
api_client: VioletPoolAPI,
|
|
1144
|
+
) -> None:
|
|
1145
|
+
"""A non-numeric service state must raise, not crash with a bare ValueError."""
|
|
1146
|
+
url = "http://192.168.1.100/getServiceStates"
|
|
1147
|
+
mock_aioresponse.get(url, payload={"proftpd": "not-a-number"}, status=200)
|
|
1148
|
+
|
|
1149
|
+
with pytest.raises(VioletPoolAPIError, match="Invalid service state"):
|
|
1150
|
+
await api_client.get_system_services()
|
|
1151
|
+
|
|
1152
|
+
|
|
1106
1153
|
# ---------------------------------------------------------------------------
|
|
1107
1154
|
# OmniTronic + RS485 + LiveTrace (P12 / P13 / P15)
|
|
1108
1155
|
# ---------------------------------------------------------------------------
|
|
@@ -1867,6 +1914,40 @@ async def test_server_error_still_counts_for_circuit_breaker(
|
|
|
1867
1914
|
assert stats["failure_count"] == 1
|
|
1868
1915
|
|
|
1869
1916
|
|
|
1917
|
+
@pytest.mark.asyncio
|
|
1918
|
+
async def test_rate_limiter_reacquired_on_every_retry(
|
|
1919
|
+
mock_aioresponse: aioresponses,
|
|
1920
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1921
|
+
) -> None:
|
|
1922
|
+
"""Each retry attempt must re-acquire a rate-limit token, not just the first.
|
|
1923
|
+
|
|
1924
|
+
Regression test: wait_if_needed() used to be awaited once before the
|
|
1925
|
+
retry loop, so retries fired real HTTP requests without ever going
|
|
1926
|
+
through the limiter again.
|
|
1927
|
+
"""
|
|
1928
|
+
url = "http://192.168.1.100/getReadings?ALL"
|
|
1929
|
+
mock_aioresponse.get(url, status=500, body="boom", repeat=True)
|
|
1930
|
+
# Backoff sleeps aren't what this test verifies; skip them for speed.
|
|
1931
|
+
monkeypatch.setattr("violet_poolcontroller_api.api.asyncio.sleep", AsyncMock())
|
|
1932
|
+
|
|
1933
|
+
async with aiohttp.ClientSession() as session:
|
|
1934
|
+
api = VioletPoolAPI(host="192.168.1.100", session=session, max_retries=3)
|
|
1935
|
+
call_count = 0
|
|
1936
|
+
original_wait = api._rate_limiter.wait_if_needed # noqa: SLF001
|
|
1937
|
+
|
|
1938
|
+
async def counting_wait(*args: object, **kwargs: object) -> None:
|
|
1939
|
+
nonlocal call_count
|
|
1940
|
+
call_count += 1
|
|
1941
|
+
await original_wait(*args, **kwargs)
|
|
1942
|
+
|
|
1943
|
+
api._rate_limiter.wait_if_needed = counting_wait # type: ignore[method-assign] # noqa: SLF001
|
|
1944
|
+
|
|
1945
|
+
with pytest.raises(VioletPoolAPIError):
|
|
1946
|
+
await api.get_readings()
|
|
1947
|
+
|
|
1948
|
+
assert call_count == 3
|
|
1949
|
+
|
|
1950
|
+
|
|
1870
1951
|
def test_command_result_error_first_line() -> None:
|
|
1871
1952
|
"""Line 1 of the response decides success per manual section 26.2."""
|
|
1872
1953
|
result = VioletPoolAPI._command_result("ERROR\nPUMP\nUNKNOWN OUTPUT")
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
|
+
# Copyright (C) 2024-2026 Xerolux
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU Affero General Public License as published
|
|
6
|
+
# by the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU Affero General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
"""Everything written into this repository is English.
|
|
18
|
+
|
|
19
|
+
The policy lives in AGENTS.md; this file is what makes it hold. Two things are
|
|
20
|
+
deliberately exempt and must stay exempt: the controller's own error strings in
|
|
21
|
+
``const_api.py`` (and the German payloads the mock server replays), which are
|
|
22
|
+
data rather than prose, and the German half of the bilingual documentation
|
|
23
|
+
under ``docs/de/``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# ruff: noqa: S101
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
import pytest
|
|
34
|
+
|
|
35
|
+
REPO = Path(__file__).parent.parent
|
|
36
|
+
PACKAGE = REPO / "violet_poolcontroller_api"
|
|
37
|
+
|
|
38
|
+
# Text the controller emits, reproduced verbatim. Translating it would break
|
|
39
|
+
# the match with what the device actually says.
|
|
40
|
+
#
|
|
41
|
+
# Matched by file name, not by path: `python -m build` copies the package into
|
|
42
|
+
# `build/lib/`, and an exemption keyed to the original path does not cover the
|
|
43
|
+
# copy. That is not hypothetical - it failed the 0.0.38 release job, because
|
|
44
|
+
# the release builds the wheel before running the checks.
|
|
45
|
+
CONTROLLER_VERBATIM = frozenset({"const_api.py", "mock_server.py"})
|
|
46
|
+
|
|
47
|
+
# This file has to name the German words it looks for.
|
|
48
|
+
SELF = Path(__file__).name
|
|
49
|
+
|
|
50
|
+
# Directories that hold generated or vendored copies of the sources. Scanning
|
|
51
|
+
# them says nothing about what is written in this repository.
|
|
52
|
+
GENERATED_DIRS = frozenset({".git", ".tox", ".venv", "venv", "build", "dist", "__pycache__"})
|
|
53
|
+
|
|
54
|
+
# Words that only appear in German prose. Deliberately not "in", "die" or "der":
|
|
55
|
+
# those collide with English or with identifiers.
|
|
56
|
+
GERMAN_WORDS = (
|
|
57
|
+
"für",
|
|
58
|
+
"über",
|
|
59
|
+
"nicht",
|
|
60
|
+
"wird",
|
|
61
|
+
"werden",
|
|
62
|
+
"wenn",
|
|
63
|
+
"diese",
|
|
64
|
+
"dieser",
|
|
65
|
+
"keine",
|
|
66
|
+
"sollte",
|
|
67
|
+
)
|
|
68
|
+
_GERMAN = re.compile(r"\b(" + "|".join(GERMAN_WORDS) + r")\b", re.IGNORECASE)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _python_sources() -> list[Path]:
|
|
72
|
+
"""Return every Python file the policy applies to."""
|
|
73
|
+
return sorted(
|
|
74
|
+
path
|
|
75
|
+
for path in REPO.rglob("*.py")
|
|
76
|
+
if not (GENERATED_DIRS & set(path.parts))
|
|
77
|
+
and not any(part.endswith(".egg-info") for part in path.parts)
|
|
78
|
+
and path.name not in CONTROLLER_VERBATIM
|
|
79
|
+
and path.name != SELF
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.parametrize("path", _python_sources(), ids=lambda p: p.name)
|
|
84
|
+
def test_python_sources_are_english(path: Path) -> None:
|
|
85
|
+
"""A German comment is read by people who do not speak German."""
|
|
86
|
+
offenders = [
|
|
87
|
+
f"{path.relative_to(REPO)}:{number}: {line.strip()}"
|
|
88
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
|
89
|
+
if _GERMAN.search(line)
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
assert not offenders, "German text outside the exempt files:\n" + "\n".join(offenders)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_the_changelog_is_english() -> None:
|
|
96
|
+
"""The changelog is what a consumer reads when a version breaks something.
|
|
97
|
+
|
|
98
|
+
Code spans are quotations, not prose: an entry that removes a German string
|
|
99
|
+
has to be able to name the string it removed.
|
|
100
|
+
"""
|
|
101
|
+
changelog = (REPO / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
102
|
+
prose = re.sub(r"`[^`]*`", "", changelog)
|
|
103
|
+
|
|
104
|
+
offenders = sorted({match.group(0).lower() for match in _GERMAN.finditer(prose)})
|
|
105
|
+
|
|
106
|
+
assert not offenders, f"German outside code spans in CHANGELOG.md: {offenders}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_the_controller_strings_stay_german() -> None:
|
|
110
|
+
"""The exemption is the point, not an oversight - guard it too.
|
|
111
|
+
|
|
112
|
+
If someone "cleans up" the error table into English, the messages stop
|
|
113
|
+
matching what the device reports.
|
|
114
|
+
"""
|
|
115
|
+
const_api = (PACKAGE / "const_api.py").read_text(encoding="utf-8")
|
|
116
|
+
|
|
117
|
+
assert "Filterdrucküberwachung (Druck zu niedrig)" in const_api
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_generated_copies_are_not_scanned() -> None:
|
|
121
|
+
"""A built wheel is not source, and scanning its copy fails the release.
|
|
122
|
+
|
|
123
|
+
`python -m build` writes `build/lib/<package>/`, so the release job runs
|
|
124
|
+
the checks with a duplicate of every module on disk.
|
|
125
|
+
"""
|
|
126
|
+
scanned = {path.name for path in _python_sources()}
|
|
127
|
+
|
|
128
|
+
assert "const_api.py" not in scanned
|
|
129
|
+
assert not [path for path in _python_sources() if "build" in path.parts]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_the_policy_is_written_down() -> None:
|
|
133
|
+
"""Without the rule in AGENTS.md this file is just an opinion."""
|
|
134
|
+
agents = (REPO / "AGENTS.md").read_text(encoding="utf-8")
|
|
135
|
+
|
|
136
|
+
assert "## Language Policy" in agents
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_readings.py
RENAMED
|
@@ -86,6 +86,37 @@ class TestVioletReadingsTypeConversions:
|
|
|
86
86
|
assert null_val is None
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
class TestVioletReadingsDosingAmounts:
|
|
90
|
+
"""Regression tests for dosing_daily_amounts_ml decimal parsing."""
|
|
91
|
+
|
|
92
|
+
def test_decimal_amounts_are_not_dropped(self):
|
|
93
|
+
"""Fractional mL amounts (the controller's actual wire format) must parse."""
|
|
94
|
+
data = {
|
|
95
|
+
"DOS_1_CL_DAILY_DOSING_AMOUNT_ML": "12.5",
|
|
96
|
+
"DOS_2_ELO_DAILY_DOSING_AMOUNT_ML": "0.0",
|
|
97
|
+
"DOS_4_PHM_DAILY_DOSING_AMOUNT_ML": "8.3",
|
|
98
|
+
"DOS_5_PHP_DAILY_DOSING_AMOUNT_ML": "0.0",
|
|
99
|
+
"DOS_6_FLOC_DAILY_DOSING_AMOUNT_ML": "3.1",
|
|
100
|
+
}
|
|
101
|
+
readings = VioletReadings(data)
|
|
102
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
103
|
+
assert amounts["DOS_1_CL"] == 12
|
|
104
|
+
assert amounts["DOS_4_PHM"] == 8
|
|
105
|
+
assert amounts["DOS_6_FLOC"] == 3
|
|
106
|
+
|
|
107
|
+
def test_missing_amount_is_none(self):
|
|
108
|
+
"""Missing keys stay None rather than raising."""
|
|
109
|
+
readings = VioletReadings({})
|
|
110
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
111
|
+
assert amounts["DOS_1_CL"] is None
|
|
112
|
+
|
|
113
|
+
def test_garbage_amount_is_none(self):
|
|
114
|
+
"""Non-numeric values fall back to None instead of raising."""
|
|
115
|
+
readings = VioletReadings({"DOS_1_CL_DAILY_DOSING_AMOUNT_ML": "not-a-number"})
|
|
116
|
+
amounts = readings.dosing_daily_amounts_ml
|
|
117
|
+
assert amounts["DOS_1_CL"] is None
|
|
118
|
+
|
|
119
|
+
|
|
89
120
|
class TestVioletReadingsAggregation:
|
|
90
121
|
"""Test readings aggregation and derived properties."""
|
|
91
122
|
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_sanitizer.py
RENAMED
|
@@ -23,3 +23,21 @@ def test_sanitize_integer_replaces_infinite_values(value: object) -> None:
|
|
|
23
23
|
def test_numeric_sanitizer_rejects_non_finite_values() -> None:
|
|
24
24
|
assert InputSanitizer.sanitize_numeric(float("nan")) == 0.0
|
|
25
25
|
assert InputSanitizer.sanitize_numeric(float("inf")) == 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_numeric_sanitizer_parses_scientific_notation() -> None:
|
|
29
|
+
"""'1e10' must parse as 1e10, not as digit-stripped '110'."""
|
|
30
|
+
assert InputSanitizer.sanitize_numeric("1e10") == 1e10
|
|
31
|
+
assert InputSanitizer.sanitize_numeric("-2.5e-3") == -2.5e-3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_numeric_sanitizer_still_extracts_digits_from_messy_strings() -> None:
|
|
35
|
+
"""Non-numeric strings still fall back to best-effort digit extraction."""
|
|
36
|
+
assert InputSanitizer.sanitize_numeric("12.5 mL") == 12.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_validate_ph_value_matches_controller_setpoint_range() -> None:
|
|
40
|
+
"""The sanitizer's pH bounds must agree with SETPOINT_RANGES (6.0-8.0)."""
|
|
41
|
+
assert InputSanitizer.validate_ph_value(8.5) == 8.0
|
|
42
|
+
assert InputSanitizer.validate_ph_value(5.0) == 6.0
|
|
43
|
+
assert InputSanitizer.validate_ph_value(7.2) == 7.2
|
|
@@ -7,8 +7,10 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
from ._api_mixin import APIClientMixin
|
|
9
9
|
from ._api_model import (
|
|
10
|
+
SETPOINT_RANGES,
|
|
10
11
|
VioletPayloadError,
|
|
11
12
|
VioletPoolAPIError,
|
|
13
|
+
VioletSetpointError,
|
|
12
14
|
validate_duration,
|
|
13
15
|
validate_setpoint,
|
|
14
16
|
)
|
|
@@ -29,6 +31,20 @@ from .const_api import (
|
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def _to_float(field: str, value: Any) -> float:
|
|
35
|
+
"""Convert ``value`` to float, raising the documented exception type.
|
|
36
|
+
|
|
37
|
+
``float()`` alone raises a bare ``ValueError``/``TypeError`` for
|
|
38
|
+
non-numeric input, which callers relying on the ``VioletSetpointError``
|
|
39
|
+
contract documented on the setpoint setters would not catch.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
return float(value)
|
|
43
|
+
except (TypeError, ValueError) as err:
|
|
44
|
+
msg = f"Invalid setpoint for '{field}': {value!r} is not a number"
|
|
45
|
+
raise VioletSetpointError(msg) from err
|
|
46
|
+
|
|
47
|
+
|
|
32
48
|
class DosingMixin(APIClientMixin):
|
|
33
49
|
"""Chemical dosing and setpoint operations."""
|
|
34
50
|
|
|
@@ -142,8 +158,9 @@ class DosingMixin(APIClientMixin):
|
|
|
142
158
|
is not a finite number.
|
|
143
159
|
|
|
144
160
|
"""
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
numeric_value = _to_float(TARGET_PH, value)
|
|
162
|
+
validate_setpoint(TARGET_PH, numeric_value)
|
|
163
|
+
return await self.set_target_value(TARGET_PH, numeric_value)
|
|
147
164
|
|
|
148
165
|
async def set_orp_target(self, value: int) -> dict[str, Any]:
|
|
149
166
|
"""Update the ORP setpoint.
|
|
@@ -159,8 +176,9 @@ class DosingMixin(APIClientMixin):
|
|
|
159
176
|
is not a finite number.
|
|
160
177
|
|
|
161
178
|
"""
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
numeric_value = _to_float(TARGET_ORP, value)
|
|
180
|
+
validate_setpoint(TARGET_ORP, numeric_value)
|
|
181
|
+
return await self.set_target_value(TARGET_ORP, int(numeric_value))
|
|
164
182
|
|
|
165
183
|
async def set_min_chlorine_level(self, value: float) -> dict[str, Any]:
|
|
166
184
|
"""Update the minimum chlorine level.
|
|
@@ -176,8 +194,9 @@ class DosingMixin(APIClientMixin):
|
|
|
176
194
|
is not a finite number.
|
|
177
195
|
|
|
178
196
|
"""
|
|
179
|
-
|
|
180
|
-
|
|
197
|
+
numeric_value = _to_float(TARGET_MIN_CHLORINE, value)
|
|
198
|
+
validate_setpoint(TARGET_MIN_CHLORINE, numeric_value)
|
|
199
|
+
return await self.set_target_value(TARGET_MIN_CHLORINE, numeric_value)
|
|
181
200
|
|
|
182
201
|
async def set_target_value(self, key: str, value: float) -> dict[str, Any]:
|
|
183
202
|
"""Send a generic target value update to the controller.
|
|
@@ -198,7 +217,8 @@ class DosingMixin(APIClientMixin):
|
|
|
198
217
|
known valid range for ``key``.
|
|
199
218
|
|
|
200
219
|
"""
|
|
201
|
-
|
|
220
|
+
numeric_value = _to_float(key, value)
|
|
221
|
+
validate_setpoint(key, numeric_value)
|
|
202
222
|
return await self.set_config({key: value})
|
|
203
223
|
|
|
204
224
|
async def set_dosing_parameters(
|
|
@@ -217,7 +237,15 @@ class DosingMixin(APIClientMixin):
|
|
|
217
237
|
Returns:
|
|
218
238
|
A dictionary with the command result.
|
|
219
239
|
|
|
240
|
+
Raises:
|
|
241
|
+
VioletSetpointError: If a known setpoint key (see
|
|
242
|
+
``SETPOINT_RANGES``, e.g. ``TARGET_PH``) is present with a
|
|
243
|
+
value outside its documented valid range.
|
|
244
|
+
|
|
220
245
|
"""
|
|
246
|
+
for key, value in parameters.items():
|
|
247
|
+
if key in SETPOINT_RANGES:
|
|
248
|
+
validate_setpoint(key, _to_float(key, value))
|
|
221
249
|
return await self.set_config(dict(parameters))
|
|
222
250
|
|
|
223
251
|
async def set_dosage_enabled(
|
|
@@ -316,7 +344,7 @@ class DosingMixin(APIClientMixin):
|
|
|
316
344
|
f"Expected one of: {sorted(DOSING_CANISTER_ID)}"
|
|
317
345
|
)
|
|
318
346
|
raise VioletPoolAPIError(msg)
|
|
319
|
-
if amount_ml <= 0:
|
|
347
|
+
if int(amount_ml) <= 0:
|
|
320
348
|
raise ValueError(f"amount_ml must be > 0, got {amount_ml}")
|
|
321
349
|
|
|
322
350
|
action = "RESET" if reset else "ADJUST"
|
|
@@ -64,9 +64,10 @@ class OutputsMixin(APIClientMixin):
|
|
|
64
64
|
|
|
65
65
|
duration_ms = validate_duration(duration) * 1000
|
|
66
66
|
payload = f"{output},{mode},{duration_ms}"
|
|
67
|
+
query = quote(payload, safe=",")
|
|
67
68
|
body = await self._request(
|
|
68
69
|
API_SET_OUTPUT_TESTMODE,
|
|
69
|
-
query=
|
|
70
|
+
query=query,
|
|
70
71
|
)
|
|
71
72
|
return self._command_result(body)
|
|
72
73
|
|
|
@@ -493,13 +494,22 @@ class OutputsMixin(APIClientMixin):
|
|
|
493
494
|
f"Expected one of {RS485_PUMP_MODES}"
|
|
494
495
|
)
|
|
495
496
|
raise VioletPoolAPIError(msg)
|
|
496
|
-
|
|
497
|
+
try:
|
|
498
|
+
slave_id_int = int(slave_id)
|
|
499
|
+
except (TypeError, ValueError) as err:
|
|
500
|
+
raise ValueError(f"slave_id must be an integer, got {slave_id!r}") from err
|
|
501
|
+
if slave_id_int < 1 or slave_id_int > 247:
|
|
497
502
|
raise ValueError(f"slave_id must be 1-247, got {slave_id}")
|
|
498
|
-
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
level_finite = math.isfinite(float(level))
|
|
506
|
+
except (TypeError, ValueError) as err:
|
|
507
|
+
raise ValueError(f"level must be a number, got {level!r}") from err
|
|
508
|
+
if not level_finite:
|
|
499
509
|
raise ValueError(f"level must be finite, got {level}")
|
|
500
510
|
|
|
501
511
|
url = (
|
|
502
|
-
f"{API_SET_RS485_LIVE}?{pump_name},{
|
|
512
|
+
f"{API_SET_RS485_LIVE}?{pump_name},{slave_id_int},"
|
|
503
513
|
f"{mode.lower()},{level}"
|
|
504
514
|
)
|
|
505
515
|
body = await self._request(
|
|
@@ -7,7 +7,7 @@ from collections.abc import Mapping
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
9
|
from ._api_mixin import APIClientMixin
|
|
10
|
-
from ._api_model import VioletPoolAPIError
|
|
10
|
+
from ._api_model import VioletPayloadError, VioletPoolAPIError
|
|
11
11
|
from .const_api import (
|
|
12
12
|
API_GET_LIVE_TRACE,
|
|
13
13
|
API_GET_LOG,
|
|
@@ -268,7 +268,12 @@ class SystemMixin(APIClientMixin):
|
|
|
268
268
|
if not state_key:
|
|
269
269
|
continue
|
|
270
270
|
if state_key in raw:
|
|
271
|
-
|
|
271
|
+
raw_value = raw[state_key]
|
|
272
|
+
try:
|
|
273
|
+
result[service] = bool(int(float(raw_value)))
|
|
274
|
+
except (TypeError, ValueError, OverflowError) as err:
|
|
275
|
+
msg = f"Invalid service state for {service}: {raw_value!r}"
|
|
276
|
+
raise VioletPayloadError(msg) from err
|
|
272
277
|
return result
|
|
273
278
|
|
|
274
279
|
async def get_live_trace(self) -> dict[str, str]:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -332,17 +332,6 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
332
332
|
raise ValueError(msg)
|
|
333
333
|
|
|
334
334
|
async def _execute_request() -> Any: # noqa: ANN401
|
|
335
|
-
# Wait if the rate limit is reached
|
|
336
|
-
try:
|
|
337
|
-
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
338
|
-
except TimeoutError:
|
|
339
|
-
_LOGGER.warning(
|
|
340
|
-
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
341
|
-
endpoint,
|
|
342
|
-
priority,
|
|
343
|
-
)
|
|
344
|
-
await asyncio.sleep(1.0)
|
|
345
|
-
|
|
346
335
|
url = self._build_url(endpoint)
|
|
347
336
|
if query:
|
|
348
337
|
url = f"{url}?{query}"
|
|
@@ -352,6 +341,18 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
352
341
|
attempt_limit = self._max_retries if should_retry else 1
|
|
353
342
|
|
|
354
343
|
for attempt in range(1, attempt_limit + 1):
|
|
344
|
+
# Wait if the rate limit is reached; re-acquired on every
|
|
345
|
+
# attempt so retries cannot bypass the per-request cap.
|
|
346
|
+
try:
|
|
347
|
+
await self._rate_limiter.wait_if_needed(priority=priority, timeout=10.0)
|
|
348
|
+
except TimeoutError:
|
|
349
|
+
_LOGGER.warning(
|
|
350
|
+
"Rate limiter timeout for %s (priority: %d) - applying fallback delay",
|
|
351
|
+
endpoint,
|
|
352
|
+
priority,
|
|
353
|
+
)
|
|
354
|
+
await asyncio.sleep(1.0)
|
|
355
|
+
|
|
355
356
|
try:
|
|
356
357
|
async with self._session.request(
|
|
357
358
|
method,
|
|
@@ -367,6 +368,9 @@ class VioletPoolAPI(ReadingsMixin, DosingMixin, OutputsMixin, SystemMixin):
|
|
|
367
368
|
response.status >= _HTTP_SERVER_ERROR
|
|
368
369
|
or response.status == _HTTP_TOO_MANY_REQUESTS
|
|
369
370
|
):
|
|
371
|
+
# Drain the body so aiohttp can return the
|
|
372
|
+
# connection to the pool instead of closing it.
|
|
373
|
+
await response.read()
|
|
370
374
|
# Server error or rate limit
|
|
371
375
|
# -> trigger retry via ClientError
|
|
372
376
|
response.raise_for_status()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -444,7 +444,7 @@ class VioletReadings(Mapping[str, Any]):
|
|
|
444
444
|
result[key] = None
|
|
445
445
|
else:
|
|
446
446
|
try:
|
|
447
|
-
result[key] = int(v)
|
|
447
|
+
result[key] = int(float(v))
|
|
448
448
|
except (ValueError, TypeError):
|
|
449
449
|
result[key] = None
|
|
450
450
|
return result
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
# You should have received a copy of the GNU Affero General Public License
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
|
-
"""Rate
|
|
17
|
+
"""Rate limiter for API requests - token bucket algorithm."""
|
|
18
18
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
@@ -34,8 +34,8 @@ class RateLimiter:
|
|
|
34
34
|
|
|
35
35
|
Verhindert API-Overload durch:
|
|
36
36
|
- Maximale Requests pro Zeitfenster
|
|
37
|
-
- Burst
|
|
38
|
-
- Priority
|
|
37
|
+
- Burst support for short spikes
|
|
38
|
+
- Priority queue for critical requests
|
|
39
39
|
- Graceful Degradation bei Limit-Überschreitung
|
|
40
40
|
"""
|
|
41
41
|
|
|
@@ -89,7 +89,7 @@ class RateLimiter:
|
|
|
89
89
|
"last_minute_reset": time.monotonic(),
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
# Lock
|
|
92
|
+
# Lock for thread safety
|
|
93
93
|
self._lock = asyncio.Lock()
|
|
94
94
|
self._waiters: list[tuple[int, int, asyncio.Event]] = []
|
|
95
95
|
self._waiter_sequence = 0
|
|
@@ -169,7 +169,7 @@ class RateLimiter:
|
|
|
169
169
|
timeout: Maximale Wartezeit in Sekunden
|
|
170
170
|
|
|
171
171
|
Raises:
|
|
172
|
-
TimeoutError:
|
|
172
|
+
TimeoutError: If the timeout is reached
|
|
173
173
|
|
|
174
174
|
"""
|
|
175
175
|
start_time = time.monotonic()
|
|
@@ -279,7 +279,7 @@ class RateLimiter:
|
|
|
279
279
|
_LOGGER.debug("Rate Limiter zurückgesetzt")
|
|
280
280
|
|
|
281
281
|
|
|
282
|
-
# Global
|
|
282
|
+
# Global rate limiter instance (one can also be created per API instance)
|
|
283
283
|
_global_rate_limiter: RateLimiter | None = None
|
|
284
284
|
|
|
285
285
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# violet-poolController-api - API
|
|
1
|
+
# violet-poolController-api - API for Violet Pool Controller
|
|
2
2
|
# Copyright (C) 2024-2026 Xerolux
|
|
3
3
|
#
|
|
4
4
|
# This program is free software: you can redistribute it and/or modify
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
"""Input
|
|
18
|
+
"""Input sanitization utilities for user inputs and API parameters."""
|
|
19
19
|
|
|
20
20
|
from __future__ import annotations
|
|
21
21
|
|
|
@@ -33,11 +33,11 @@ _MAX_API_PARAM_LENGTH = 100
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
class InputSanitizer:
|
|
36
|
-
"""Input
|
|
36
|
+
"""Input sanitization for security and data integrity.
|
|
37
37
|
|
|
38
38
|
Schützt vor:
|
|
39
39
|
- XSS (Cross-Site Scripting)
|
|
40
|
-
- SQL
|
|
40
|
+
- SQL injection (not relevant for an HTTP API, but defended against)
|
|
41
41
|
- Command Injection
|
|
42
42
|
- Path Traversal
|
|
43
43
|
- Unerwarteten Zeichen
|
|
@@ -85,14 +85,14 @@ class InputSanitizer:
|
|
|
85
85
|
# Konvertiere zu String
|
|
86
86
|
str_value = str(value).strip()
|
|
87
87
|
|
|
88
|
-
# Unicode
|
|
88
|
+
# Unicode normalisation (NFKD, defence in depth)
|
|
89
89
|
# Normalized Form Compatibility Decomposition
|
|
90
90
|
str_value = unicodedata.normalize("NFKD", str_value)
|
|
91
91
|
|
|
92
92
|
# Längen-Validierung
|
|
93
93
|
if len(str_value) > max_length:
|
|
94
94
|
_LOGGER.warning(
|
|
95
|
-
"String
|
|
95
|
+
"String too long (%d > %d), truncating: %s...",
|
|
96
96
|
len(str_value),
|
|
97
97
|
max_length,
|
|
98
98
|
str_value[:50],
|
|
@@ -132,6 +132,16 @@ class InputSanitizer:
|
|
|
132
132
|
return float(value)
|
|
133
133
|
|
|
134
134
|
str_value = str(value).strip()
|
|
135
|
+
try:
|
|
136
|
+
parsed = float(str_value)
|
|
137
|
+
except ValueError:
|
|
138
|
+
pass
|
|
139
|
+
else:
|
|
140
|
+
if not math.isfinite(parsed):
|
|
141
|
+
_LOGGER.warning("Unendlicher/NaN-Wert: %s", value)
|
|
142
|
+
return 0.0
|
|
143
|
+
return parsed
|
|
144
|
+
|
|
135
145
|
has_minus = str_value.startswith("-")
|
|
136
146
|
cleaned = re.sub(r"[^0-9.]", "", str_value)
|
|
137
147
|
if has_minus and cleaned:
|
|
@@ -223,7 +233,7 @@ class InputSanitizer:
|
|
|
223
233
|
float_value = float(value)
|
|
224
234
|
if not math.isfinite(float_value):
|
|
225
235
|
_LOGGER.warning(
|
|
226
|
-
"
|
|
236
|
+
"Non-finite float value '%s', using default %.2f",
|
|
227
237
|
value,
|
|
228
238
|
default,
|
|
229
239
|
)
|
|
@@ -305,7 +315,7 @@ class InputSanitizer:
|
|
|
305
315
|
|
|
306
316
|
"""
|
|
307
317
|
if not key:
|
|
308
|
-
msg = "Device
|
|
318
|
+
msg = "Device key must not be empty"
|
|
309
319
|
raise ValueError(msg)
|
|
310
320
|
|
|
311
321
|
# Nur Großbuchstaben, Zahlen und Underscore erlaubt
|
|
@@ -341,7 +351,7 @@ class InputSanitizer:
|
|
|
341
351
|
|
|
342
352
|
"""
|
|
343
353
|
if not param:
|
|
344
|
-
msg = "API
|
|
354
|
+
msg = "API parameter must not be empty"
|
|
345
355
|
raise ValueError(msg)
|
|
346
356
|
|
|
347
357
|
# Prüfe auf Path Traversal VOR der Bereinigung
|
|
@@ -398,13 +408,14 @@ class InputSanitizer:
|
|
|
398
408
|
ph: pH-Wert
|
|
399
409
|
|
|
400
410
|
Returns:
|
|
401
|
-
Validierter pH-Wert (6.0-
|
|
411
|
+
Validierter pH-Wert (6.0-8.0), passend zum vom Controller
|
|
412
|
+
akzeptierten Sollwertbereich (siehe ``SETPOINT_RANGES``).
|
|
402
413
|
|
|
403
414
|
"""
|
|
404
415
|
return InputSanitizer.sanitize_float(
|
|
405
416
|
ph,
|
|
406
417
|
min_value=6.0,
|
|
407
|
-
max_value=
|
|
418
|
+
max_value=8.0,
|
|
408
419
|
precision=1,
|
|
409
420
|
default=7.2,
|
|
410
421
|
)
|
|
@@ -447,27 +458,27 @@ class InputSanitizer:
|
|
|
447
458
|
)
|
|
448
459
|
|
|
449
460
|
|
|
450
|
-
# Singleton
|
|
461
|
+
# Singleton instance for convenient access
|
|
451
462
|
_sanitizer = InputSanitizer()
|
|
452
463
|
|
|
453
464
|
|
|
454
465
|
def sanitize_string(*args: Any, **kwargs: Any) -> str: # noqa: ANN401
|
|
455
|
-
"""Shortcut
|
|
466
|
+
"""Shortcut for InputSanitizer.sanitize_string()."""
|
|
456
467
|
return _sanitizer.sanitize_string(*args, **kwargs)
|
|
457
468
|
|
|
458
469
|
|
|
459
470
|
def sanitize_integer(*args: Any, **kwargs: Any) -> int: # noqa: ANN401
|
|
460
|
-
"""Shortcut
|
|
471
|
+
"""Shortcut for InputSanitizer.sanitize_integer()."""
|
|
461
472
|
return _sanitizer.sanitize_integer(*args, **kwargs)
|
|
462
473
|
|
|
463
474
|
|
|
464
475
|
def sanitize_float(*args: Any, **kwargs: Any) -> float: # noqa: ANN401
|
|
465
|
-
"""Shortcut
|
|
476
|
+
"""Shortcut for InputSanitizer.sanitize_float()."""
|
|
466
477
|
return _sanitizer.sanitize_float(*args, **kwargs)
|
|
467
478
|
|
|
468
479
|
|
|
469
480
|
def sanitize_boolean(*args: Any, **kwargs: Any) -> bool: # noqa: ANN401
|
|
470
|
-
"""Shortcut
|
|
481
|
+
"""Shortcut for InputSanitizer.sanitize_boolean()."""
|
|
471
482
|
return _sanitizer.sanitize_boolean(*args, **kwargs)
|
|
472
483
|
|
|
473
484
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_api_smoke.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_circuit_breaker.py
RENAMED
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_mock_server.py
RENAMED
|
File without changes
|
|
File without changes
|
{violet_poolcontroller_api-0.0.36 → violet_poolcontroller_api-0.0.38}/tests/test_rate_limiter.py
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
|