bwt-api 0.6.1__tar.gz → 1.0.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.
- {bwt_api-0.6.1 → bwt_api-1.0.0}/PKG-INFO +1 -1
- bwt_api-1.0.0/src/bwt_api/api.py +13 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/bwt.py +15 -2
- bwt_api-0.6.1/src/bwt_api/api.py → bwt_api-1.0.0/src/bwt_api/bwt_api.py +14 -56
- bwt_api-1.0.0/src/bwt_api/data.py +172 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/error.py +10 -1
- bwt_api-1.0.0/src/bwt_api/silk_api.py +45 -0
- bwt_api-1.0.0/src/bwt_api/smart_dos_api.py +137 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/PKG-INFO +1 -1
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/SOURCES.txt +5 -0
- bwt_api-1.0.0/src/bwt_api.egg-info/scm_file_list.json +32 -0
- bwt_api-1.0.0/src/bwt_api.egg-info/scm_version.json +8 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/test_api.py +95 -3
- bwt_api-0.6.1/src/bwt_api/data.py +0 -69
- {bwt_api-0.6.1 → bwt_api-1.0.0}/.coveragerc +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/.devcontainer/devcontainer.json +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/.github/workflows/python-package.yml +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/.github/workflows/python-publish.yml +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/.gitignore +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/AUTHORS.rst +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/CHANGELOG.rst +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/CONTRIBUTING.rst +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/LICENSE.txt +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/README.rst +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/pyproject.toml +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/setup.cfg +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/setup.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/__init__.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/exception.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/skeleton.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/dependency_links.txt +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/entry_points.txt +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/not-zip-safe +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/requires.txt +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/top_level.txt +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/conftest.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/test_distinguish.py +0 -0
- {bwt_api-0.6.1 → bwt_api-1.0.0}/tox.ini +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""API facade for the BWT package."""
|
|
2
|
+
|
|
3
|
+
from bwt_api.bwt_api import BwtApi
|
|
4
|
+
from bwt_api.silk_api import BwtSilkApi
|
|
5
|
+
from bwt_api.smart_dos_api import BwtSmartDosApi
|
|
6
|
+
|
|
7
|
+
__all__ = ["BwtApi", "BwtSilkApi", "BwtSmartDosApi"]
|
|
8
|
+
|
|
9
|
+
def treated_to_blended(treated: int, hardness_in: int, hardness_out: int) -> float:
|
|
10
|
+
if hardness_in == 0 or hardness_in == hardness_out:
|
|
11
|
+
return treated
|
|
12
|
+
|
|
13
|
+
return treated / (1.0 - hardness_out / hardness_in)
|
|
@@ -13,13 +13,15 @@ class BwtModel(Enum):
|
|
|
13
13
|
"""Enum for BWT models."""
|
|
14
14
|
PERLA_LOCAL_API = 1
|
|
15
15
|
PERLA_SILK = 2
|
|
16
|
+
SMART_DOS = 3
|
|
16
17
|
|
|
17
18
|
async def determine_bwt_model(host: str) -> BwtModel:
|
|
18
19
|
"""Determine the BWT model based on the api response."""
|
|
19
20
|
|
|
20
21
|
_logger.debug(f"Determining BWT model for host {host}")
|
|
21
22
|
timeout = aiohttp.ClientTimeout(total=3)
|
|
22
|
-
|
|
23
|
+
|
|
24
|
+
# Recent BWT Perla models with local API
|
|
23
25
|
try:
|
|
24
26
|
async with aiohttp.ClientSession() as session:
|
|
25
27
|
async with session.get(f"http://{host}:8080/api", timeout=timeout) as response:
|
|
@@ -30,7 +32,7 @@ async def determine_bwt_model(host: str) -> BwtModel:
|
|
|
30
32
|
except Exception:
|
|
31
33
|
pass
|
|
32
34
|
|
|
33
|
-
#
|
|
35
|
+
# Perla Silk with registers endpoint that returns a list of raw data
|
|
34
36
|
try:
|
|
35
37
|
async with aiohttp.ClientSession() as session:
|
|
36
38
|
async with session.get(f"http://{host}:80/silk/registers", timeout=timeout) as response:
|
|
@@ -41,4 +43,15 @@ async def determine_bwt_model(host: str) -> BwtModel:
|
|
|
41
43
|
except Exception:
|
|
42
44
|
pass
|
|
43
45
|
|
|
46
|
+
# Smart Dos
|
|
47
|
+
try:
|
|
48
|
+
async with aiohttp.ClientSession() as session:
|
|
49
|
+
async with session.get(f"http://{host}:80/api/v1/gatt/0201", timeout=timeout) as response:
|
|
50
|
+
res = await response.text()
|
|
51
|
+
_logger.debug(f"Response from {host}:80/api/v1/gatt/0201: {response.status} - {res}")
|
|
52
|
+
if response.status == 200 and res.startswith("""{"""):
|
|
53
|
+
return BwtModel.SMART_DOS
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
44
57
|
raise ConnectException(f"Could not determine BWT model for host {host}. Please check the connection or the host address.")
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
"""The BWT API class."""
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
from tokenize import String
|
|
5
3
|
import aiohttp
|
|
6
4
|
import base64
|
|
7
5
|
import logging
|
|
@@ -13,11 +11,12 @@ from bwt_api.data import CurrentResponse, DailyResponse, MonthlyResponse, Yearly
|
|
|
13
11
|
|
|
14
12
|
_logger = logging.getLogger(__name__)
|
|
15
13
|
|
|
14
|
+
|
|
16
15
|
class BwtApi:
|
|
17
16
|
"""BWT Api."""
|
|
18
17
|
_session: aiohttp.ClientSession
|
|
19
18
|
_host: str
|
|
20
|
-
_headers: dict[str,str]
|
|
19
|
+
_headers: dict[str, str]
|
|
21
20
|
|
|
22
21
|
def __init__(self, host, code):
|
|
23
22
|
self._host = host
|
|
@@ -25,7 +24,7 @@ class BwtApi:
|
|
|
25
24
|
base64_auth = base64.b64encode(auth.encode("ascii")).decode("ascii")
|
|
26
25
|
self._headers = {"Authorization": f"Basic {base64_auth}"}
|
|
27
26
|
self._session = aiohttp.ClientSession(headers=self._headers)
|
|
28
|
-
|
|
27
|
+
|
|
29
28
|
async def __aenter__(self):
|
|
30
29
|
return self
|
|
31
30
|
|
|
@@ -39,27 +38,29 @@ class BwtApi:
|
|
|
39
38
|
"""Internal method to fetch json from the endpoint and handle general errors."""
|
|
40
39
|
try:
|
|
41
40
|
async with self._session.get(f"http://{self._host}:8080/api/{endpoint}") as response:
|
|
42
|
-
_logger.debug(
|
|
43
|
-
|
|
41
|
+
_logger.debug(
|
|
42
|
+
f"Response status: {response.status}, content-type: {response.headers['content-type']}"
|
|
43
|
+
)
|
|
44
|
+
if response.status == 200:
|
|
44
45
|
json = await response.json(content_type=None)
|
|
45
46
|
_logger.debug(f"Raw response: {json}")
|
|
46
47
|
return json
|
|
47
48
|
else:
|
|
48
49
|
text = await response.text()
|
|
49
|
-
if
|
|
50
|
+
if response.status == 404 and text == "":
|
|
50
51
|
_logger.warning(f"Assuming wrong code: {response}")
|
|
51
52
|
raise WrongCodeException
|
|
52
53
|
else:
|
|
53
54
|
_logger.warning(f"Unknown response with status {response.status}: {text}")
|
|
54
55
|
raise ApiException(f"Unknown response: {text}")
|
|
55
|
-
except aiohttp.ClientConnectorError as e:
|
|
56
|
+
except (aiohttp.ClientConnectorError, TimeoutError) as e:
|
|
56
57
|
raise ConnectException from e
|
|
58
|
+
except (aiohttp.ContentTypeError, ValueError) as e:
|
|
59
|
+
raise ApiException from e
|
|
57
60
|
|
|
58
61
|
def _convert_datetime(self, input: str) -> datetime:
|
|
59
62
|
# It looks like the device sends and even shows everything in local time
|
|
60
|
-
return datetime.strptime(
|
|
61
|
-
input, "%Y-%m-%d %H:%M:%S"
|
|
62
|
-
)# .replace(tzinfo=ZoneInfo("UTC"))
|
|
63
|
+
return datetime.strptime(input, "%Y-%m-%d %H:%M:%S")
|
|
63
64
|
|
|
64
65
|
async def get_current_data(self) -> CurrentResponse:
|
|
65
66
|
"""Get the current state of the BWT."""
|
|
@@ -69,7 +70,7 @@ class BwtApi:
|
|
|
69
70
|
if BwtError.UNKNOWN in errors:
|
|
70
71
|
_logger.warning(f"Unknown error in current data response {raw['ActiveErrorIDs']}")
|
|
71
72
|
perla_one = raw["CapacityColumn2_ml_dH"] == -1
|
|
72
|
-
|
|
73
|
+
|
|
73
74
|
in_hardness = Hardness(
|
|
74
75
|
raw["HardnessIN_CaCO3"],
|
|
75
76
|
raw["HardnessIN_dH"],
|
|
@@ -112,10 +113,9 @@ class BwtApi:
|
|
|
112
113
|
)
|
|
113
114
|
|
|
114
115
|
async def get_daily_data(self) -> DailyResponse:
|
|
115
|
-
"""
|
|
116
|
+
"""Get treated water of the current day."""
|
|
116
117
|
_logger.debug(f"Fetching daily data from {self._host}")
|
|
117
118
|
raw = await self.__get_data("GetDailyData")
|
|
118
|
-
# Build the keys. Starting with "00:00_00:39_l" up to "23:30_23:59_l"
|
|
119
119
|
keys = [
|
|
120
120
|
f"{min // 60:02}{min % 60:02}_{min // 60:02}{min % 60 + 29:02}_l"
|
|
121
121
|
for min in range(0, 1440, 30)
|
|
@@ -135,45 +135,3 @@ class BwtApi:
|
|
|
135
135
|
raw = await self.__get_data("GetYearlyData")
|
|
136
136
|
keys = [f"Month{month:02}_l" for month in range(1, 13)]
|
|
137
137
|
return YearlyResponse(list(map(lambda k: raw[k], keys)))
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
class BwtSilkApi:
|
|
141
|
-
"""BWT Silk Api."""
|
|
142
|
-
_session: aiohttp.ClientSession
|
|
143
|
-
_host: str
|
|
144
|
-
|
|
145
|
-
def __init__(self, host):
|
|
146
|
-
self._host = host
|
|
147
|
-
self._session = aiohttp.ClientSession()
|
|
148
|
-
|
|
149
|
-
async def __aenter__(self):
|
|
150
|
-
return self
|
|
151
|
-
|
|
152
|
-
async def __aexit__(self, *err):
|
|
153
|
-
await self.close()
|
|
154
|
-
|
|
155
|
-
async def close(self):
|
|
156
|
-
await self._session.close()
|
|
157
|
-
|
|
158
|
-
async def get_registers(self) -> list[int]:
|
|
159
|
-
"""Internal method to fetch json from the endpoint and handle general errors."""
|
|
160
|
-
try:
|
|
161
|
-
async with self._session.get(f"http://{self._host}:80/silk/registers") as response:
|
|
162
|
-
_logger.debug(f"Response status: {response.status}, content-type: {response.headers['content-type']}")
|
|
163
|
-
if (response.status == 200):
|
|
164
|
-
json = await response.json(content_type=None)
|
|
165
|
-
_logger.debug(f"Raw response: {json}")
|
|
166
|
-
return json["params"]
|
|
167
|
-
else:
|
|
168
|
-
text = await response.text()
|
|
169
|
-
_logger.warning(f"Unknown response with status {response.status}: {text}")
|
|
170
|
-
raise ApiException(f"Unknown response: {text}")
|
|
171
|
-
except aiohttp.ClientConnectorError as e:
|
|
172
|
-
raise ConnectException from e
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def treated_to_blended(treated: int, hardness_in: int, hardness_out: int) -> float:
|
|
176
|
-
if (hardness_in == 0 | hardness_in == hardness_out):
|
|
177
|
-
return treated
|
|
178
|
-
|
|
179
|
-
return treated / (1.0 - hardness_out / hardness_in)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Data objects received from the API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from bwt_api.error import BwtError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BwtStatus(enum.Enum):
|
|
12
|
+
OK = 0
|
|
13
|
+
WARNING = 1
|
|
14
|
+
ERROR = 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SmartDosStatus(enum.IntEnum):
|
|
18
|
+
STANDBY = 2001
|
|
19
|
+
METERING_ACTIVE = 2002
|
|
20
|
+
MINERAL_CONTAINER_LEVEL_LOW = 7001
|
|
21
|
+
MINERAL_CONTAINER_EMPTY = 7002
|
|
22
|
+
SMART_MINERAL_RUNNING_LOW = 7003
|
|
23
|
+
SMART_MINERAL_RUN_OUT = 7004
|
|
24
|
+
AQA_VOLUME_ALARM = 7005
|
|
25
|
+
AQA_WATCH_ALARM = 7006
|
|
26
|
+
AQA_MAXFLOW_ALARM = 7007
|
|
27
|
+
PUMP_FAULT = 8001
|
|
28
|
+
PUMP_POWER_FAULT = 8002
|
|
29
|
+
PUMP_CONTROL_FAULT = 8003
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def _missing_(cls, value):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SubstanceType(enum.IntEnum):
|
|
37
|
+
L1_LE = 1
|
|
38
|
+
L2_L3 = 2
|
|
39
|
+
L4 = 3
|
|
40
|
+
CU2 = 4
|
|
41
|
+
FLUSHING_SOLUTION = 5
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def _missing_(cls, value):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Hardness:
|
|
50
|
+
caco3: int # ppm CACO3
|
|
51
|
+
dH: int # dH
|
|
52
|
+
fH: int # fH
|
|
53
|
+
mmol: int # mmol / l
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CurrentResponse:
|
|
58
|
+
errors: list[BwtError]
|
|
59
|
+
blended_total: int # l
|
|
60
|
+
capacity_1: int # ml * dH
|
|
61
|
+
capacity_2: int # ml * dH
|
|
62
|
+
current_flow: int # l / h
|
|
63
|
+
dosing_total: int # ml
|
|
64
|
+
firmware_version: str
|
|
65
|
+
in_hardness: Hardness
|
|
66
|
+
out_hardness: Hardness
|
|
67
|
+
holiday_mode: int # -1 or 0: inactive, 1: active, unix timestamp: start in future
|
|
68
|
+
regeneration_last_1: datetime
|
|
69
|
+
regeneration_last_2: datetime
|
|
70
|
+
service_customer: datetime
|
|
71
|
+
service_technician: datetime
|
|
72
|
+
out_of_service: int
|
|
73
|
+
regeneration_count_1: int
|
|
74
|
+
regeneration_count_2: int
|
|
75
|
+
regeneration_count: int
|
|
76
|
+
regenerativ_level: int # %
|
|
77
|
+
regenerativ_days: int # days left
|
|
78
|
+
regenerativ_total: int # g
|
|
79
|
+
state: BwtStatus
|
|
80
|
+
treated_day: int # treated water current day
|
|
81
|
+
treated_month: int # treated water current month
|
|
82
|
+
treated_year: int # treated water current year
|
|
83
|
+
columns: int # number of columns: 2 for BWT Duo and 1 for BWT Perla One
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DailyResponse:
|
|
88
|
+
# treated water in 30 minute intervals
|
|
89
|
+
# [0] = 00:00 - 00:29
|
|
90
|
+
# [1] = 00:30 - 00:59
|
|
91
|
+
values: list[int]
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class MonthlyResponse:
|
|
95
|
+
# treated water in 1 day intervals
|
|
96
|
+
values: list[int]
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class YearlyResponse:
|
|
100
|
+
# treated water in 1 month intervals
|
|
101
|
+
values: list[int]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# Smart DOS API Data Classes
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class WifiResponse:
|
|
108
|
+
"""UUID 0104: Wi-Fi Information"""
|
|
109
|
+
ssid: str # Wi-Fi network name
|
|
110
|
+
rssi: int # Wi-Fi signal strength in dBm
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class DeviceInfoResponse:
|
|
115
|
+
"""UUID 0201: Device Information"""
|
|
116
|
+
fw_rev: str # Firmware Revision
|
|
117
|
+
hw_rev: str # Hardware Revision
|
|
118
|
+
product_code: str # Product Code
|
|
119
|
+
uptime: int # Operating time since last reboot (seconds)
|
|
120
|
+
operating_time: int # Operating time since first reboot (seconds)
|
|
121
|
+
dev_state: SmartDosStatus | None # Current device status
|
|
122
|
+
active_states: list[SmartDosStatus | None] # List of active device statuses
|
|
123
|
+
comm_date: str # Start-up date (ISO format)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class ConfigurationResponse:
|
|
128
|
+
"""UUID 0202: Configuration"""
|
|
129
|
+
buzzer_en: bool # Buzzer active
|
|
130
|
+
dosing_rate: float # Metering rate (ml/m³)
|
|
131
|
+
aqa_volume_en: bool # AQA Volume active
|
|
132
|
+
aqa_watch_en: bool # AQA Watch active
|
|
133
|
+
aqa_max_flow_en: bool # AQA MaxFlow active
|
|
134
|
+
aqa_volume_val: float # AQA Volume value (l)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class TimeResponse:
|
|
139
|
+
"""UUID 0208: Time and Timezone"""
|
|
140
|
+
time: int # Unix timestamp (seconds since 1970-01-01)
|
|
141
|
+
timezone: str # Timezone string
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class PouchInfoResponse:
|
|
146
|
+
"""UUID 0401: Pouch/Container Information"""
|
|
147
|
+
tot_cap: float # Pouch volume (ml)
|
|
148
|
+
exp_date: str # Substance expiry date (DD.MM.YYYY)
|
|
149
|
+
order_nr: int # Order number
|
|
150
|
+
batch_nr: int # Batch number
|
|
151
|
+
substance_type: SubstanceType | None # Substance type ID
|
|
152
|
+
unit: int # Unit (0 = ml)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class RemainingCapacityResponse:
|
|
157
|
+
"""UUID 0402: Remaining Capacity"""
|
|
158
|
+
rem_capacity: float # Remaining volume in ml
|
|
159
|
+
rem_capacity_pct: float # Remaining volume in %
|
|
160
|
+
rem_capacity_days: int # Remaining volume in days
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class TreatedWaterResponse:
|
|
165
|
+
"""UUID 0503: Treated Water"""
|
|
166
|
+
total_flow: int # Total treated water in ml
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class SubstanceDosageResponse:
|
|
171
|
+
"""UUID 0505: Substance Dosage"""
|
|
172
|
+
dosed_mineral: float # Substance dosage quantity since start-up (ml)
|
|
@@ -57,10 +57,19 @@ class BwtError(enum.Enum):
|
|
|
57
57
|
|
|
58
58
|
@classmethod
|
|
59
59
|
def _missing_(cls, value):
|
|
60
|
-
obj = cls
|
|
60
|
+
obj = object.__new__(cls)
|
|
61
61
|
obj._value_ = value
|
|
62
|
+
obj._name_ = f"UNKNOWN_{value}"
|
|
62
63
|
return obj
|
|
63
64
|
|
|
65
|
+
def __eq__(self, other):
|
|
66
|
+
if isinstance(other, BwtError):
|
|
67
|
+
return self._value_ == other._value_
|
|
68
|
+
return NotImplemented
|
|
69
|
+
|
|
70
|
+
def __hash__(self):
|
|
71
|
+
return hash(self._value_)
|
|
72
|
+
|
|
64
73
|
def is_fatal(self) -> bool:
|
|
65
74
|
return self not in WARNING_CODES
|
|
66
75
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The BWT Silk API class."""
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from bwt_api.exception import ApiException, ConnectException
|
|
7
|
+
|
|
8
|
+
_logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BwtSilkApi:
|
|
12
|
+
"""BWT Silk Api."""
|
|
13
|
+
_session: aiohttp.ClientSession
|
|
14
|
+
_host: str
|
|
15
|
+
|
|
16
|
+
def __init__(self, host):
|
|
17
|
+
self._host = host
|
|
18
|
+
self._session = aiohttp.ClientSession()
|
|
19
|
+
|
|
20
|
+
async def __aenter__(self):
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
async def __aexit__(self, *err):
|
|
24
|
+
await self.close()
|
|
25
|
+
|
|
26
|
+
async def close(self):
|
|
27
|
+
await self._session.close()
|
|
28
|
+
|
|
29
|
+
async def get_registers(self) -> list[int]:
|
|
30
|
+
"""Internal method to fetch json from the endpoint and handle general errors."""
|
|
31
|
+
try:
|
|
32
|
+
async with self._session.get(f"http://{self._host}:80/silk/registers") as response:
|
|
33
|
+
_logger.debug(
|
|
34
|
+
f"Response status: {response.status}, content-type: {response.headers['content-type']}"
|
|
35
|
+
)
|
|
36
|
+
if response.status == 200:
|
|
37
|
+
json = await response.json(content_type=None)
|
|
38
|
+
_logger.debug(f"Raw response: {json}")
|
|
39
|
+
return json["params"]
|
|
40
|
+
else:
|
|
41
|
+
text = await response.text()
|
|
42
|
+
_logger.warning(f"Unknown response with status {response.status}: {text}")
|
|
43
|
+
raise ApiException(f"Unknown response: {text}")
|
|
44
|
+
except aiohttp.ClientConnectorError as e:
|
|
45
|
+
raise ConnectException from e
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The BWT Smart Dos API class."""
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from bwt_api.data import (
|
|
8
|
+
WifiResponse,
|
|
9
|
+
DeviceInfoResponse,
|
|
10
|
+
SmartDosStatus,
|
|
11
|
+
SubstanceType,
|
|
12
|
+
ConfigurationResponse,
|
|
13
|
+
TimeResponse,
|
|
14
|
+
PouchInfoResponse,
|
|
15
|
+
RemainingCapacityResponse,
|
|
16
|
+
TreatedWaterResponse,
|
|
17
|
+
SubstanceDosageResponse,
|
|
18
|
+
)
|
|
19
|
+
from bwt_api.exception import ApiException, ConnectException
|
|
20
|
+
|
|
21
|
+
_logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BwtSmartDosApi:
|
|
25
|
+
"""BWT Smart Dos Api."""
|
|
26
|
+
_session: aiohttp.ClientSession
|
|
27
|
+
_host: str
|
|
28
|
+
|
|
29
|
+
def __init__(self, host: str):
|
|
30
|
+
self._host = host
|
|
31
|
+
self._session = aiohttp.ClientSession()
|
|
32
|
+
|
|
33
|
+
async def __aenter__(self):
|
|
34
|
+
return self
|
|
35
|
+
|
|
36
|
+
async def __aexit__(self, *err):
|
|
37
|
+
await self.close()
|
|
38
|
+
|
|
39
|
+
async def close(self):
|
|
40
|
+
await self._session.close()
|
|
41
|
+
|
|
42
|
+
async def _get_gatt(self, uuid: str) -> dict[str, Any]:
|
|
43
|
+
"""Internal method to fetch GATT characteristic JSON."""
|
|
44
|
+
try:
|
|
45
|
+
async with self._session.get(f"http://{self._host}:80/api/v1/gatt/{uuid}") as response:
|
|
46
|
+
_logger.debug(
|
|
47
|
+
f"Response status: {response.status}, content-type: {response.headers['content-type']}"
|
|
48
|
+
)
|
|
49
|
+
if response.status == 200:
|
|
50
|
+
json = await response.json(content_type=None)
|
|
51
|
+
_logger.debug(f"Raw response for UUID {uuid}: {json}")
|
|
52
|
+
return json
|
|
53
|
+
text = await response.text()
|
|
54
|
+
_logger.warning(f"Unknown response with status {response.status}: {text}")
|
|
55
|
+
raise ApiException(f"Unknown response: {text}")
|
|
56
|
+
except aiohttp.ClientConnectorError as e:
|
|
57
|
+
raise ConnectException from e
|
|
58
|
+
|
|
59
|
+
async def get_wifi_info(self) -> WifiResponse:
|
|
60
|
+
"""UUID 0104: Get Wi-Fi name and signal strength."""
|
|
61
|
+
_logger.debug(f"Fetching Wi-Fi info from {self._host}")
|
|
62
|
+
raw = await self._get_gatt("0104")
|
|
63
|
+
return WifiResponse(ssid=raw["ssid"], rssi=raw["rssi"])
|
|
64
|
+
|
|
65
|
+
async def get_device_info(self) -> DeviceInfoResponse:
|
|
66
|
+
"""UUID 0201: Get device information."""
|
|
67
|
+
_logger.debug(f"Fetching device info from {self._host}")
|
|
68
|
+
raw = await self._get_gatt("0201")
|
|
69
|
+
return DeviceInfoResponse(
|
|
70
|
+
fw_rev=raw["fwRev"],
|
|
71
|
+
hw_rev=raw["hwRev"],
|
|
72
|
+
product_code=raw["productCode"],
|
|
73
|
+
uptime=raw["uptime"],
|
|
74
|
+
operating_time=raw["operatingTime"],
|
|
75
|
+
dev_state=SmartDosStatus(raw["devState"]),
|
|
76
|
+
active_states=[SmartDosStatus(state) for state in raw["activeStates"]],
|
|
77
|
+
comm_date=raw["commDate"],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def get_configuration(self) -> ConfigurationResponse:
|
|
81
|
+
"""UUID 0202: Get device configuration."""
|
|
82
|
+
_logger.debug(f"Fetching configuration from {self._host}")
|
|
83
|
+
raw = await self._get_gatt("0202")
|
|
84
|
+
return ConfigurationResponse(
|
|
85
|
+
buzzer_en=raw["buzzerEn"],
|
|
86
|
+
dosing_rate=raw["dosingRate"],
|
|
87
|
+
aqa_volume_en=raw["aqaVolumeEn"],
|
|
88
|
+
aqa_watch_en=raw["aqaWatchEn"],
|
|
89
|
+
aqa_max_flow_en=raw["aqaMaxFlowEn"],
|
|
90
|
+
aqa_volume_val=raw["aqaVolumeVal"],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def get_time_info(self) -> TimeResponse:
|
|
94
|
+
"""UUID 0208: Get time and timezone information."""
|
|
95
|
+
_logger.debug(f"Fetching time info from {self._host}")
|
|
96
|
+
raw = await self._get_gatt("0208")
|
|
97
|
+
return TimeResponse(time=raw["time"], timezone=raw["timezone"])
|
|
98
|
+
|
|
99
|
+
async def get_pouch_info(self) -> PouchInfoResponse:
|
|
100
|
+
"""UUID 0401: Get pouch/container information."""
|
|
101
|
+
_logger.debug(f"Fetching pouch info from {self._host}")
|
|
102
|
+
raw = await self._get_gatt("0401")
|
|
103
|
+
return PouchInfoResponse(
|
|
104
|
+
tot_cap=raw["totCap"],
|
|
105
|
+
exp_date=raw["expDate"],
|
|
106
|
+
order_nr=raw["orderNr"],
|
|
107
|
+
batch_nr=raw["batchNr"],
|
|
108
|
+
substance_type=SubstanceType(raw["id"]),
|
|
109
|
+
unit=raw["unit"],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
async def get_remaining_capacity(self) -> RemainingCapacityResponse:
|
|
113
|
+
"""UUID 0402: Get remaining capacity information."""
|
|
114
|
+
_logger.debug(f"Fetching remaining capacity from {self._host}")
|
|
115
|
+
raw = await self._get_gatt("0402")
|
|
116
|
+
return RemainingCapacityResponse(
|
|
117
|
+
rem_capacity=raw["remCapacity"],
|
|
118
|
+
rem_capacity_pct=raw["remCapacityPct"],
|
|
119
|
+
rem_capacity_days=raw["remCapacityDays"],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def get_treated_water(self) -> TreatedWaterResponse:
|
|
123
|
+
"""UUID 0503: Get treated water information."""
|
|
124
|
+
_logger.debug(f"Fetching treated water from {self._host}")
|
|
125
|
+
raw = await self._get_gatt("0503")
|
|
126
|
+
total_flow = raw["flow"]["1"]["totFlow"]
|
|
127
|
+
return TreatedWaterResponse(total_flow=total_flow)
|
|
128
|
+
|
|
129
|
+
async def get_substance_dosage(self) -> SubstanceDosageResponse:
|
|
130
|
+
"""UUID 0505: Get substance dosage information."""
|
|
131
|
+
_logger.debug(f"Fetching substance dosage from {self._host}")
|
|
132
|
+
raw = await self._get_gatt("0505")
|
|
133
|
+
return SubstanceDosageResponse(dosed_mineral=raw["dosedMineral"])
|
|
134
|
+
|
|
135
|
+
async def get_gatt_0201(self) -> dict[str, Any]:
|
|
136
|
+
"""Fetch the Smart Dos GATT 0201 characteristic JSON (raw)."""
|
|
137
|
+
return await self._get_gatt("0201")
|
|
@@ -15,16 +15,21 @@ tox.ini
|
|
|
15
15
|
src/bwt_api/__init__.py
|
|
16
16
|
src/bwt_api/api.py
|
|
17
17
|
src/bwt_api/bwt.py
|
|
18
|
+
src/bwt_api/bwt_api.py
|
|
18
19
|
src/bwt_api/data.py
|
|
19
20
|
src/bwt_api/error.py
|
|
20
21
|
src/bwt_api/exception.py
|
|
22
|
+
src/bwt_api/silk_api.py
|
|
21
23
|
src/bwt_api/skeleton.py
|
|
24
|
+
src/bwt_api/smart_dos_api.py
|
|
22
25
|
src/bwt_api.egg-info/PKG-INFO
|
|
23
26
|
src/bwt_api.egg-info/SOURCES.txt
|
|
24
27
|
src/bwt_api.egg-info/dependency_links.txt
|
|
25
28
|
src/bwt_api.egg-info/entry_points.txt
|
|
26
29
|
src/bwt_api.egg-info/not-zip-safe
|
|
27
30
|
src/bwt_api.egg-info/requires.txt
|
|
31
|
+
src/bwt_api.egg-info/scm_file_list.json
|
|
32
|
+
src/bwt_api.egg-info/scm_version.json
|
|
28
33
|
src/bwt_api.egg-info/top_level.txt
|
|
29
34
|
tests/conftest.py
|
|
30
35
|
tests/test_api.py
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": [
|
|
3
|
+
".coveragerc",
|
|
4
|
+
"setup.cfg",
|
|
5
|
+
"tox.ini",
|
|
6
|
+
"LICENSE.txt",
|
|
7
|
+
"setup.py",
|
|
8
|
+
"pyproject.toml",
|
|
9
|
+
"CHANGELOG.rst",
|
|
10
|
+
"CONTRIBUTING.rst",
|
|
11
|
+
"README.rst",
|
|
12
|
+
".gitignore",
|
|
13
|
+
"AUTHORS.rst",
|
|
14
|
+
".devcontainer/devcontainer.json",
|
|
15
|
+
"src/bwt_api/data.py",
|
|
16
|
+
"src/bwt_api/error.py",
|
|
17
|
+
"src/bwt_api/__init__.py",
|
|
18
|
+
"src/bwt_api/silk_api.py",
|
|
19
|
+
"src/bwt_api/exception.py",
|
|
20
|
+
"src/bwt_api/api.py",
|
|
21
|
+
"src/bwt_api/bwt_api.py",
|
|
22
|
+
"src/bwt_api/bwt.py",
|
|
23
|
+
"src/bwt_api/smart_dos_api.py",
|
|
24
|
+
"src/bwt_api/bwt_api",
|
|
25
|
+
"src/bwt_api/skeleton.py",
|
|
26
|
+
"tests/test_api.py",
|
|
27
|
+
"tests/conftest.py",
|
|
28
|
+
"tests/test_distinguish.py",
|
|
29
|
+
".github/workflows/python-publish.yml",
|
|
30
|
+
".github/workflows/python-package.yml"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
from zoneinfo import ZoneInfo
|
|
3
|
+
from unittest.mock import Mock
|
|
3
4
|
import pytest
|
|
5
|
+
from aiohttp.client_reqrep import ClientResponse
|
|
4
6
|
|
|
5
|
-
from bwt_api.api import BwtApi, treated_to_blended
|
|
7
|
+
from bwt_api.api import BwtApi, BwtSmartDosApi, treated_to_blended
|
|
6
8
|
from bwt_api.error import BwtError
|
|
7
|
-
from bwt_api.data import CurrentResponse, Hardness, BwtStatus
|
|
9
|
+
from bwt_api.data import CurrentResponse, Hardness, BwtStatus, SmartDosStatus, SubstanceType
|
|
8
10
|
|
|
9
11
|
from aioresponses import aioresponses
|
|
10
12
|
|
|
13
|
+
# Compatibility shim for aioresponses with aiohttp 3.14+.
|
|
14
|
+
_original_client_response_init = ClientResponse.__init__
|
|
15
|
+
|
|
16
|
+
def _compat_client_response_init(self, method, url, *args, stream_writer=None, **kwargs):
|
|
17
|
+
if stream_writer is None:
|
|
18
|
+
stream_writer = Mock(output_size=0)
|
|
19
|
+
return _original_client_response_init(self, method, url, *args, stream_writer=stream_writer, **kwargs)
|
|
20
|
+
|
|
21
|
+
ClientResponse.__init__ = _compat_client_response_init
|
|
22
|
+
|
|
11
23
|
from bwt_api.exception import ApiException, ConnectException, WrongCodeException
|
|
12
24
|
|
|
13
25
|
__author__ = "dkarv"
|
|
@@ -140,6 +152,15 @@ async def test_connect_error():
|
|
|
140
152
|
await api.get_current_data()
|
|
141
153
|
|
|
142
154
|
|
|
155
|
+
async def test_timeout_error():
|
|
156
|
+
"""Timeouts should be wrapped as ConnectException, not escape as raw TimeoutError."""
|
|
157
|
+
with aioresponses() as mocked:
|
|
158
|
+
mocked.get("http://host:8080/api/GetCurrentData", exception=TimeoutError())
|
|
159
|
+
async with BwtApi("host", "code") as api:
|
|
160
|
+
with pytest.raises(ConnectException):
|
|
161
|
+
await api.get_current_data()
|
|
162
|
+
|
|
163
|
+
|
|
143
164
|
async def test_unknown_response():
|
|
144
165
|
with aioresponses() as mocked:
|
|
145
166
|
mocked.get("http://host:8080/api/GetCurrentData", status=400, body="")
|
|
@@ -148,6 +169,15 @@ async def test_unknown_response():
|
|
|
148
169
|
await api.get_current_data()
|
|
149
170
|
|
|
150
171
|
|
|
172
|
+
async def test_invalid_json_response():
|
|
173
|
+
"""HTTP 200 with non-JSON body should raise ApiException, not JSONDecodeError."""
|
|
174
|
+
with aioresponses() as mocked:
|
|
175
|
+
mocked.get("http://host:8080/api/GetCurrentData", status=200, body="not json")
|
|
176
|
+
async with BwtApi("host", "code") as api:
|
|
177
|
+
with pytest.raises(ApiException):
|
|
178
|
+
await api.get_current_data()
|
|
179
|
+
|
|
180
|
+
|
|
151
181
|
async def test_current_data():
|
|
152
182
|
with aioresponses() as mocked:
|
|
153
183
|
mocked.get("http://host:8080/api/GetCurrentData", status=200, body=current_json)
|
|
@@ -158,7 +188,7 @@ async def test_current_data():
|
|
|
158
188
|
BwtError.REGENERATIV_20,
|
|
159
189
|
BwtError.MAINTENANCE_CUSTOMER,
|
|
160
190
|
BwtError.MAINTENANCE_SERVICE,
|
|
161
|
-
BwtError
|
|
191
|
+
BwtError(29),
|
|
162
192
|
],
|
|
163
193
|
blended_total=318383,
|
|
164
194
|
capacity_1=5485275,
|
|
@@ -257,9 +287,71 @@ async def test_perla_one():
|
|
|
257
287
|
columns=1,
|
|
258
288
|
)
|
|
259
289
|
|
|
290
|
+
def test_unknown_error_no_mutation():
|
|
291
|
+
"""Unknown error codes must not mutate the UNKNOWN singleton."""
|
|
292
|
+
err1 = BwtError(29)
|
|
293
|
+
assert err1.value == 29
|
|
294
|
+
assert err1.name == "UNKNOWN_29"
|
|
295
|
+
err2 = BwtError(123)
|
|
296
|
+
assert err2.value == 123
|
|
297
|
+
assert err2.name == "UNKNOWN_123"
|
|
298
|
+
# UNKNOWN singleton must be untouched
|
|
299
|
+
assert BwtError.UNKNOWN.value == -1
|
|
300
|
+
# Different unknown codes produce different instances
|
|
301
|
+
assert err1 is not err2
|
|
302
|
+
|
|
303
|
+
async def test_smartdos_get_gatt_0201():
|
|
304
|
+
with aioresponses() as mocked:
|
|
305
|
+
mocked.get(
|
|
306
|
+
"http://host:80/api/v1/gatt/0201",
|
|
307
|
+
status=200,
|
|
308
|
+
body='{"characteristic":"0201","value":[1,2,3]}',
|
|
309
|
+
headers={"Content-Type": "application/json"},
|
|
310
|
+
)
|
|
311
|
+
async with BwtSmartDosApi("host") as api:
|
|
312
|
+
result = await api.get_gatt_0201()
|
|
313
|
+
assert result == {"characteristic": "0201", "value": [1, 2, 3]}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
async def test_smartdos_unknown_response():
|
|
317
|
+
with aioresponses() as mocked:
|
|
318
|
+
mocked.get("http://host:80/api/v1/gatt/0201", status=404, body="Not Found")
|
|
319
|
+
async with BwtSmartDosApi("host") as api:
|
|
320
|
+
with pytest.raises(ApiException):
|
|
321
|
+
await api.get_gatt_0201()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def test_smartdos_device_info_parses_status_values():
|
|
325
|
+
with aioresponses() as mocked:
|
|
326
|
+
mocked.get(
|
|
327
|
+
"http://host:80/api/v1/gatt/0201",
|
|
328
|
+
status=200,
|
|
329
|
+
body='{"fwRev":"1.1.0+4","hwRev":"2.4.0(B)","productCode":"8R19-CX2A","uptime":4889,"operatingTime":706889,"devState":2001,"activeStates":[2001],"commDate":"2024-12-04T13:45:38.833Z"}',
|
|
330
|
+
headers={"Content-Type": "application/json"},
|
|
331
|
+
)
|
|
332
|
+
async with BwtSmartDosApi("host") as api:
|
|
333
|
+
result = await api.get_device_info()
|
|
334
|
+
assert result.dev_state == SmartDosStatus.STANDBY
|
|
335
|
+
assert result.active_states == [SmartDosStatus.STANDBY]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
async def test_smartdos_pouch_info_parses_substance_type():
|
|
339
|
+
with aioresponses() as mocked:
|
|
340
|
+
mocked.get(
|
|
341
|
+
"http://host:80/api/v1/gatt/0401",
|
|
342
|
+
status=200,
|
|
343
|
+
body='{"totCap":10000,"expDate":"05.12.2025","orderNr":125123456,"batchNr":12345,"id":2,"unit":0}',
|
|
344
|
+
headers={"Content-Type": "application/json"},
|
|
345
|
+
)
|
|
346
|
+
async with BwtSmartDosApi("host") as api:
|
|
347
|
+
result = await api.get_pouch_info()
|
|
348
|
+
assert result.substance_type == SubstanceType.L2_L3
|
|
349
|
+
|
|
260
350
|
def test_treated_to_blended():
|
|
261
351
|
assert treated_to_blended(0, 21, 4) == 0
|
|
262
352
|
assert treated_to_blended(100, 21, 21) == 100
|
|
263
353
|
assert treated_to_blended(10, 20, 4) == 12.5
|
|
264
354
|
assert treated_to_blended(306, 21, 4) == 378
|
|
265
355
|
assert treated_to_blended(191, 21, 4) == pytest.approx(235.9411)
|
|
356
|
+
# Edge case: hardness_in == 0 should return treated as-is, not divide by zero
|
|
357
|
+
assert treated_to_blended(100, 0, 0) == 100
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
"""Data objects received from the API."""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import enum
|
|
5
|
-
|
|
6
|
-
from dataclasses import dataclass
|
|
7
|
-
from datetime import datetime
|
|
8
|
-
from bwt_api.error import BwtError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class BwtStatus(enum.Enum):
|
|
12
|
-
OK = 0
|
|
13
|
-
WARNING = 1
|
|
14
|
-
ERROR = 2
|
|
15
|
-
|
|
16
|
-
@dataclass
|
|
17
|
-
class Hardness:
|
|
18
|
-
caco3: int # ppm CACO3
|
|
19
|
-
dH: int # dH
|
|
20
|
-
fH: int # fH
|
|
21
|
-
mmol: int # mmol / l
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class CurrentResponse:
|
|
26
|
-
errors: list[BwtError]
|
|
27
|
-
blended_total: int # l
|
|
28
|
-
capacity_1: int # ml * dH
|
|
29
|
-
capacity_2: int # ml * dH
|
|
30
|
-
current_flow: int # l / h
|
|
31
|
-
dosing_total: int # ml
|
|
32
|
-
firmware_version: str
|
|
33
|
-
in_hardness: Hardness
|
|
34
|
-
out_hardness: Hardness
|
|
35
|
-
holiday_mode: int # -1 or 0: inactive, 1: active, unix timestamp: start in future
|
|
36
|
-
regeneration_last_1: datetime
|
|
37
|
-
regeneration_last_2: datetime
|
|
38
|
-
service_customer: datetime
|
|
39
|
-
service_technician: datetime
|
|
40
|
-
out_of_service: int
|
|
41
|
-
regeneration_count_1: int
|
|
42
|
-
regeneration_count_2: int
|
|
43
|
-
regeneration_count: int
|
|
44
|
-
regenerativ_level: int # %
|
|
45
|
-
regenerativ_days: int # days left
|
|
46
|
-
regenerativ_total: int # g
|
|
47
|
-
state: BwtStatus
|
|
48
|
-
treated_day: int # treated water current day
|
|
49
|
-
treated_month: int # treated water current month
|
|
50
|
-
treated_year: int # treated water current year
|
|
51
|
-
columns: int # number of columns: 2 for BWT Duo and 1 for BWT Perla One
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@dataclass
|
|
55
|
-
class DailyResponse:
|
|
56
|
-
# treated water in 30 minute intervals
|
|
57
|
-
# [0] = 00:00 - 00:29
|
|
58
|
-
# [1] = 00:30 - 00:59
|
|
59
|
-
values: list[int]
|
|
60
|
-
|
|
61
|
-
@dataclass
|
|
62
|
-
class MonthlyResponse:
|
|
63
|
-
# treated water in 1 day intervals
|
|
64
|
-
values: list[int]
|
|
65
|
-
|
|
66
|
-
@dataclass
|
|
67
|
-
class YearlyResponse:
|
|
68
|
-
# treated water in 1 month intervals
|
|
69
|
-
values: list[int]
|
|
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
|