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.
Files changed (38) hide show
  1. {bwt_api-0.6.1 → 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.1 → bwt_api-1.0.0}/src/bwt_api/bwt.py +15 -2
  4. bwt_api-0.6.1/src/bwt_api/api.py → bwt_api-1.0.0/src/bwt_api/bwt_api.py +14 -56
  5. bwt_api-1.0.0/src/bwt_api/data.py +172 -0
  6. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/error.py +10 -1
  7. bwt_api-1.0.0/src/bwt_api/silk_api.py +45 -0
  8. bwt_api-1.0.0/src/bwt_api/smart_dos_api.py +137 -0
  9. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/PKG-INFO +1 -1
  10. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/SOURCES.txt +5 -0
  11. bwt_api-1.0.0/src/bwt_api.egg-info/scm_file_list.json +32 -0
  12. bwt_api-1.0.0/src/bwt_api.egg-info/scm_version.json +8 -0
  13. {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/test_api.py +95 -3
  14. bwt_api-0.6.1/src/bwt_api/data.py +0 -69
  15. {bwt_api-0.6.1 → bwt_api-1.0.0}/.coveragerc +0 -0
  16. {bwt_api-0.6.1 → bwt_api-1.0.0}/.devcontainer/devcontainer.json +0 -0
  17. {bwt_api-0.6.1 → bwt_api-1.0.0}/.github/workflows/python-package.yml +0 -0
  18. {bwt_api-0.6.1 → bwt_api-1.0.0}/.github/workflows/python-publish.yml +0 -0
  19. {bwt_api-0.6.1 → bwt_api-1.0.0}/.gitignore +0 -0
  20. {bwt_api-0.6.1 → bwt_api-1.0.0}/AUTHORS.rst +0 -0
  21. {bwt_api-0.6.1 → bwt_api-1.0.0}/CHANGELOG.rst +0 -0
  22. {bwt_api-0.6.1 → bwt_api-1.0.0}/CONTRIBUTING.rst +0 -0
  23. {bwt_api-0.6.1 → bwt_api-1.0.0}/LICENSE.txt +0 -0
  24. {bwt_api-0.6.1 → bwt_api-1.0.0}/README.rst +0 -0
  25. {bwt_api-0.6.1 → bwt_api-1.0.0}/pyproject.toml +0 -0
  26. {bwt_api-0.6.1 → bwt_api-1.0.0}/setup.cfg +0 -0
  27. {bwt_api-0.6.1 → bwt_api-1.0.0}/setup.py +0 -0
  28. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/__init__.py +0 -0
  29. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/exception.py +0 -0
  30. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api/skeleton.py +0 -0
  31. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/dependency_links.txt +0 -0
  32. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/entry_points.txt +0 -0
  33. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/not-zip-safe +0 -0
  34. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/requires.txt +0 -0
  35. {bwt_api-0.6.1 → bwt_api-1.0.0}/src/bwt_api.egg-info/top_level.txt +0 -0
  36. {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/conftest.py +0 -0
  37. {bwt_api-0.6.1 → bwt_api-1.0.0}/tests/test_distinguish.py +0 -0
  38. {bwt_api-0.6.1 → 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.1
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,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(f"Response status: {response.status}, content-type: {response.headers['content-type']}")
43
- if (response.status == 200):
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 (response.status == 404 and text == ""):
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
- """"Get treated water of the current day."""
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.UNKNOWN
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")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bwt_api
3
- Version: 0.6.1
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"
@@ -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.UNKNOWN,
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