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.
Files changed (38) hide show
  1. {bwt_api-0.6.2 → bwt_api-1.0.0}/PKG-INFO +1 -1
  2. bwt_api-1.0.0/src/bwt_api/api.py +13 -0
  3. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/bwt.py +15 -2
  4. bwt_api-0.6.2/src/bwt_api/api.py → bwt_api-1.0.0/src/bwt_api/bwt_api.py +15 -63
  5. bwt_api-1.0.0/src/bwt_api/data.py +172 -0
  6. bwt_api-1.0.0/src/bwt_api/silk_api.py +45 -0
  7. bwt_api-1.0.0/src/bwt_api/smart_dos_api.py +137 -0
  8. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/PKG-INFO +1 -1
  9. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/SOURCES.txt +5 -0
  10. bwt_api-1.0.0/src/bwt_api.egg-info/scm_file_list.json +32 -0
  11. bwt_api-1.0.0/src/bwt_api.egg-info/scm_version.json +8 -0
  12. {bwt_api-0.6.2 → bwt_api-1.0.0}/tests/test_api.py +60 -2
  13. bwt_api-0.6.2/src/bwt_api/data.py +0 -69
  14. {bwt_api-0.6.2 → bwt_api-1.0.0}/.coveragerc +0 -0
  15. {bwt_api-0.6.2 → bwt_api-1.0.0}/.devcontainer/devcontainer.json +0 -0
  16. {bwt_api-0.6.2 → bwt_api-1.0.0}/.github/workflows/python-package.yml +0 -0
  17. {bwt_api-0.6.2 → bwt_api-1.0.0}/.github/workflows/python-publish.yml +0 -0
  18. {bwt_api-0.6.2 → bwt_api-1.0.0}/.gitignore +0 -0
  19. {bwt_api-0.6.2 → bwt_api-1.0.0}/AUTHORS.rst +0 -0
  20. {bwt_api-0.6.2 → bwt_api-1.0.0}/CHANGELOG.rst +0 -0
  21. {bwt_api-0.6.2 → bwt_api-1.0.0}/CONTRIBUTING.rst +0 -0
  22. {bwt_api-0.6.2 → bwt_api-1.0.0}/LICENSE.txt +0 -0
  23. {bwt_api-0.6.2 → bwt_api-1.0.0}/README.rst +0 -0
  24. {bwt_api-0.6.2 → bwt_api-1.0.0}/pyproject.toml +0 -0
  25. {bwt_api-0.6.2 → bwt_api-1.0.0}/setup.cfg +0 -0
  26. {bwt_api-0.6.2 → bwt_api-1.0.0}/setup.py +0 -0
  27. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/__init__.py +0 -0
  28. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/error.py +0 -0
  29. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/exception.py +0 -0
  30. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api/skeleton.py +0 -0
  31. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/dependency_links.txt +0 -0
  32. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/entry_points.txt +0 -0
  33. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/not-zip-safe +0 -0
  34. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/requires.txt +0 -0
  35. {bwt_api-0.6.2 → bwt_api-1.0.0}/src/bwt_api.egg-info/top_level.txt +0 -0
  36. {bwt_api-0.6.2 → bwt_api-1.0.0}/tests/conftest.py +0 -0
  37. {bwt_api-0.6.2 → bwt_api-1.0.0}/tests/test_distinguish.py +0 -0
  38. {bwt_api-0.6.2 → bwt_api-1.0.0}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bwt_api
3
- Version: 0.6.2
3
+ Version: 1.0.0
4
4
  Summary: Python API to access the local BWT Perla API.
5
5
  Home-page: https://github.com/dkarv/bwt_api
6
6
  Author: dkarv
@@ -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
- # Try :8080/api/GetCurrentData
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
- # Try :80/status
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(f"Response status: {response.status}, content-type: {response.headers['content-type']}")
43
- if (response.status == 200):
44
- try:
45
- json = await response.json(content_type=None)
46
- except ValueError as e:
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 (response.status == 404 and text == ""):
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 any(e.name.startswith("UNKNOWN") for e in errors):
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
- """"Get treated water of the current day."""
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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bwt_api
3
- Version: 0.6.2
3
+ Version: 1.0.0
4
4
  Summary: Python API to access the local BWT Perla API.
5
5
  Home-page: https://github.com/dkarv/bwt_api
6
6
  Author: dkarv
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.0.0",
3
+ "distance": 0,
4
+ "node": "g5492b4bbc37a2bb17fa43aa4c44738964f42a13f",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-07-14"
8
+ }
@@ -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