bwt-api 0.6.2__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.2 → 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.2 → bwt_api-1.0.0}/src/bwt_api/bwt.py +15 -2
- bwt_api-0.6.2/src/bwt_api/api.py → bwt_api-1.0.0/src/bwt_api/bwt_api.py +15 -63
- bwt_api-1.0.0/src/bwt_api/data.py +172 -0
- 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.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/PKG-INFO +1 -1
- {bwt_api-0.6.2 → 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.2 → bwt_api-1.0.0}/tests/test_api.py +60 -2
- bwt_api-0.6.2/src/bwt_api/data.py +0 -69
- {bwt_api-0.6.2 → bwt_api-1.0.0}/.coveragerc +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/.devcontainer/devcontainer.json +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/.github/workflows/python-package.yml +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/.github/workflows/python-publish.yml +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/.gitignore +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/AUTHORS.rst +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/CHANGELOG.rst +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/CONTRIBUTING.rst +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/LICENSE.txt +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/README.rst +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/pyproject.toml +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/setup.cfg +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/setup.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/__init__.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/error.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/exception.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/skeleton.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/dependency_links.txt +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/entry_points.txt +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/not-zip-safe +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/requires.txt +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/top_level.txt +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/tests/conftest.py +0 -0
- {bwt_api-0.6.2 → bwt_api-1.0.0}/tests/test_distinguish.py +0 -0
- {bwt_api-0.6.2 → 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,17 +38,16 @@ 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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
raise ApiException(f"Invalid JSON response from {endpoint}") from e
|
|
41
|
+
_logger.debug(
|
|
42
|
+
f"Response status: {response.status}, content-type: {response.headers['content-type']}"
|
|
43
|
+
)
|
|
44
|
+
if response.status == 200:
|
|
45
|
+
json = await response.json(content_type=None)
|
|
48
46
|
_logger.debug(f"Raw response: {json}")
|
|
49
47
|
return json
|
|
50
48
|
else:
|
|
51
49
|
text = await response.text()
|
|
52
|
-
if
|
|
50
|
+
if response.status == 404 and text == "":
|
|
53
51
|
_logger.warning(f"Assuming wrong code: {response}")
|
|
54
52
|
raise WrongCodeException
|
|
55
53
|
else:
|
|
@@ -57,22 +55,22 @@ class BwtApi:
|
|
|
57
55
|
raise ApiException(f"Unknown response: {text}")
|
|
58
56
|
except (aiohttp.ClientConnectorError, TimeoutError) as e:
|
|
59
57
|
raise ConnectException from e
|
|
58
|
+
except (aiohttp.ContentTypeError, ValueError) as e:
|
|
59
|
+
raise ApiException from e
|
|
60
60
|
|
|
61
61
|
def _convert_datetime(self, input: str) -> datetime:
|
|
62
62
|
# It looks like the device sends and even shows everything in local time
|
|
63
|
-
return datetime.strptime(
|
|
64
|
-
input, "%Y-%m-%d %H:%M:%S"
|
|
65
|
-
)# .replace(tzinfo=ZoneInfo("UTC"))
|
|
63
|
+
return datetime.strptime(input, "%Y-%m-%d %H:%M:%S")
|
|
66
64
|
|
|
67
65
|
async def get_current_data(self) -> CurrentResponse:
|
|
68
66
|
"""Get the current state of the BWT."""
|
|
69
67
|
_logger.debug(f"Fetching current data from {self._host}")
|
|
70
68
|
raw = await self.__get_data("GetCurrentData")
|
|
71
69
|
errors = [BwtError(int(error)) for error in raw["ActiveErrorIDs"].split(",") if error]
|
|
72
|
-
if
|
|
70
|
+
if BwtError.UNKNOWN in errors:
|
|
73
71
|
_logger.warning(f"Unknown error in current data response {raw['ActiveErrorIDs']}")
|
|
74
72
|
perla_one = raw["CapacityColumn2_ml_dH"] == -1
|
|
75
|
-
|
|
73
|
+
|
|
76
74
|
in_hardness = Hardness(
|
|
77
75
|
raw["HardnessIN_CaCO3"],
|
|
78
76
|
raw["HardnessIN_dH"],
|
|
@@ -115,10 +113,9 @@ class BwtApi:
|
|
|
115
113
|
)
|
|
116
114
|
|
|
117
115
|
async def get_daily_data(self) -> DailyResponse:
|
|
118
|
-
"""
|
|
116
|
+
"""Get treated water of the current day."""
|
|
119
117
|
_logger.debug(f"Fetching daily data from {self._host}")
|
|
120
118
|
raw = await self.__get_data("GetDailyData")
|
|
121
|
-
# Build the keys. Starting with "00:00_00:39_l" up to "23:30_23:59_l"
|
|
122
119
|
keys = [
|
|
123
120
|
f"{min // 60:02}{min % 60:02}_{min // 60:02}{min % 60 + 29:02}_l"
|
|
124
121
|
for min in range(0, 1440, 30)
|
|
@@ -138,48 +135,3 @@ class BwtApi:
|
|
|
138
135
|
raw = await self.__get_data("GetYearlyData")
|
|
139
136
|
keys = [f"Month{month:02}_l" for month in range(1, 13)]
|
|
140
137
|
return YearlyResponse(list(map(lambda k: raw[k], keys)))
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
class BwtSilkApi:
|
|
144
|
-
"""BWT Silk Api."""
|
|
145
|
-
_session: aiohttp.ClientSession
|
|
146
|
-
_host: str
|
|
147
|
-
|
|
148
|
-
def __init__(self, host):
|
|
149
|
-
self._host = host
|
|
150
|
-
self._session = aiohttp.ClientSession()
|
|
151
|
-
|
|
152
|
-
async def __aenter__(self):
|
|
153
|
-
return self
|
|
154
|
-
|
|
155
|
-
async def __aexit__(self, *err):
|
|
156
|
-
await self.close()
|
|
157
|
-
|
|
158
|
-
async def close(self):
|
|
159
|
-
await self._session.close()
|
|
160
|
-
|
|
161
|
-
async def get_registers(self) -> list[int]:
|
|
162
|
-
"""Internal method to fetch json from the endpoint and handle general errors."""
|
|
163
|
-
try:
|
|
164
|
-
async with self._session.get(f"http://{self._host}:80/silk/registers") as response:
|
|
165
|
-
_logger.debug(f"Response status: {response.status}, content-type: {response.headers['content-type']}")
|
|
166
|
-
if (response.status == 200):
|
|
167
|
-
try:
|
|
168
|
-
json = await response.json(content_type=None)
|
|
169
|
-
except ValueError as e:
|
|
170
|
-
raise ApiException(f"Invalid JSON response from silk/registers") from e
|
|
171
|
-
_logger.debug(f"Raw response: {json}")
|
|
172
|
-
return json["params"]
|
|
173
|
-
else:
|
|
174
|
-
text = await response.text()
|
|
175
|
-
_logger.warning(f"Unknown response with status {response.status}: {text}")
|
|
176
|
-
raise ApiException(f"Unknown response: {text}")
|
|
177
|
-
except (aiohttp.ClientConnectorError, TimeoutError) as e:
|
|
178
|
-
raise ConnectException from e
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def treated_to_blended(treated: int, hardness_in: int, hardness_out: int) -> float:
|
|
182
|
-
if hardness_in == 0 or hardness_in == hardness_out:
|
|
183
|
-
return treated
|
|
184
|
-
|
|
185
|
-
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)
|
|
@@ -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"
|
|
@@ -288,6 +300,52 @@ def test_unknown_error_no_mutation():
|
|
|
288
300
|
# Different unknown codes produce different instances
|
|
289
301
|
assert err1 is not err2
|
|
290
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
|
|
291
349
|
|
|
292
350
|
def test_treated_to_blended():
|
|
293
351
|
assert treated_to_blended(0, 21, 4) == 0
|
|
@@ -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
|
|
File without changes
|