juham-watermeter 0.0.4__py3-none-any.whl → 0.0.6__py3-none-any.whl

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.
@@ -1,328 +1,327 @@
1
- """Web camera based optical watermeter based on differences between subsequent frames.
2
-
3
- """
4
-
5
- import json
6
- import os
7
- import subprocess
8
- import tempfile
9
- import time
10
- import cv2
11
- import numpy as np
12
- from typing import Any, Optional, Union, cast
13
- from typing_extensions import override
14
- from masterpiece.mqtt import Mqtt
15
-
16
- from juham_core.timeutils import timestamp
17
- from .webcamera import WebCameraThread, WebCamera
18
-
19
-
20
- class WaterMeterThreadImgDiff(WebCameraThread):
21
- """Asynchronous thread for capturing and processing images of web camera.
22
- Uploads three images to sftp server for inspection: the original watermeter
23
- image, the image holding differences between the last captured watermeter image,
24
- and processed image with noise eliminated, and differences scaled up for maximum
25
- image contrast: white pixels represent areas. The more white pixels, the bigger the
26
- water consumption.
27
- """
28
-
29
- # class attributes
30
- _watermeter_topic: str = ""
31
- _expected_image_size: int = 640 * 480
32
- _save_images: bool = True
33
- _calibration_factor: float = 1000.0 # img diff to liters
34
- _max_change_area: float = 0.1 # difference can't be greater than this
35
- ftp_site: str = ""
36
- ftp_user: str = ""
37
- ftp_pw: str = ""
38
-
39
- def __init__(self, client: Optional[Mqtt] = None):
40
- """Construct with the given mqtt client.
41
-
42
- Args:
43
- client (object, optional): MQTT client. Defaults to None.
44
- """
45
- super().__init__(client)
46
- self.sensor_name = "watermeter_imgdiff"
47
- self.mqtt_client: Optional[Mqtt] = client
48
- self.total_liter: float = 0.0
49
- self.active_liter_lpm: float = 0.0
50
- self._prev_image: np.ndarray = np.zeros((1, 1, 3), dtype=np.uint8)
51
- self._prev_image_initialized = False
52
- self._prev_time: float = 0.0
53
-
54
- # total cumulative diff between two consequtive images
55
- self._wm_start_seconds: float = 0.0
56
-
57
- # temp filenames for saving the original and processed images, for debugging purposes
58
- self._temp_filename1: str = ""
59
- self._temp_filename2: str = ""
60
-
61
- def init_watermeter_imgdiff(
62
- self,
63
- interval: float,
64
- location: str,
65
- camera: int,
66
- topic: str,
67
- save_images: bool,
68
- ) -> None:
69
- """Initialize the data acquisition thread
70
-
71
- Args:
72
- topic (str): mqtt topic to publish the acquired system info
73
- interval (float): update interval in seconds
74
- location (str): geographic location
75
- camera(int) : ordinal specifying the camera to be used (0, 1)
76
- save_images (bool) : true to enable saving of captured images, for debugging
77
- """
78
- super().init(interval, location, camera)
79
- self._watermeter_topic = topic
80
- self._save_images = save_images
81
- # temp filenames for saving the original and processed images, for debugging purposes
82
- tmpdir: str = tempfile.mkdtemp()
83
- self._temp_filename1 = os.path.join(tmpdir, f"{self.sensor_name}_wm_1.png")
84
- self._temp_filename2 = os.path.join(tmpdir, f"{self.sensor_name}_wm_2.png")
85
-
86
- def upload_image(self, file: str, img: np.ndarray) -> None:
87
- """Save the image to the given file and upload the file to the FTP server.
88
-
89
- Args:
90
- file (str): The filename where the image will be saved.
91
- img (np.ndarray): The image to be saved and uploaded.
92
- """
93
- # Save the image to the specified file
94
- cv2.imwrite(file, img)
95
-
96
- try:
97
- # Upload the file to the FTP server
98
- self.upload_file(file)
99
-
100
- # If the upload is successful, remove the file
101
- os.remove(file)
102
- print(f"File {file} uploaded and deleted successfully.")
103
-
104
- except Exception as e:
105
- # Handle any errors that occurred during upload
106
- self.error(f"Error during file upload: {e}")
107
-
108
- def compare_images(
109
- self,
110
- np_prev: np.ndarray,
111
- np_current: np.ndarray,
112
- threshold: int = 20,
113
- ) -> float:
114
- """
115
- Compares two images and returns a float value representing the level of differences.
116
-
117
- Parameters:
118
- np_prev (np.ndarray): The previous image.
119
- np_current (np.ndarray): The current image.
120
- threshold (int): Threshold value to filter noise in the difference image.
121
-
122
- Returns:
123
- float: A value between 0.0 and 1.0 indicating the level of difference.
124
- 0.0 means identical, 1.0 means maximally different.
125
- """
126
- # Ensure both images are the same size and type
127
- if np_prev.shape != np_current.shape:
128
- self.error("Images have different shapes or dimensions.")
129
- return -1.0 # Error value for different shapes
130
-
131
- # Step 1: Calculate the absolute difference between the two images
132
- diff_image = cv2.absdiff(np_prev, np_current)
133
-
134
- # Step 2: Filter small differences (noise) using a binary threshold
135
- _, binary_diff = cv2.threshold(diff_image, threshold, 255, cv2.THRESH_BINARY)
136
-
137
- # Step 3: Calculate the proportion of changed pixels
138
- change_area: float = np.count_nonzero(binary_diff) / binary_diff.size
139
-
140
- if change_area > 0.0:
141
- print(f"Watermeter change {change_area}, uploading")
142
- self.upload_images(np_current, binary_diff)
143
-
144
- # Return a value between 0.0 (identical) and 1.0 (maximally different)
145
- return min(max(change_area, 0.0), 1.0)
146
-
147
- @override
148
- def update_interval(self) -> float:
149
- return self._interval
150
-
151
- def upload_file(self, filename: str) -> None:
152
- """Upload the given filename to the ftp server, if the server is configured.
153
-
154
- Args:
155
- filename (str): _description_
156
- """
157
- if len(self.ftp_site) > 0 and len(self.ftp_user) > 0 and len(self.ftp_pw) > 0:
158
-
159
- # Build the curl command for uploading the file
160
- curl_command = [
161
- "curl",
162
- f"-u{self.ftp_user}:{self.ftp_pw}",
163
- "--retry",
164
- "3",
165
- "--retry-delay",
166
- "5",
167
- "-T",
168
- filename,
169
- self.ftp_site,
170
- ]
171
-
172
- # Execute the curl command
173
- try:
174
- subprocess.run(curl_command, check=True)
175
- except subprocess.CalledProcessError as e:
176
- self.error(f"Error during image upload: {e}")
177
-
178
- def upload_images(self, np_watermeter: np.ndarray, np_diff: np.ndarray) -> None:
179
- """Upload captured grayscale watermeter image, and the diff image to ftp site
180
-
181
- Parameters:
182
- np_watermeter (np.ndarray): Watermeter image in grayscale
183
- np_diff (np.ndarray): The diff image reflecting consumed water
184
- """
185
- self.upload_image(self._temp_filename1, np_watermeter)
186
- self.upload_image(self._temp_filename2, np_diff)
187
-
188
- @override
189
- def update(self) -> bool:
190
- change_area: float = -1
191
- captured_image = self.capture_image()
192
- if captured_image.size < self._expected_image_size:
193
- return False
194
- grayscale_image = self.process_image(captured_image)
195
- if grayscale_image.size < self._expected_image_size:
196
- return False
197
- processed_image = self.enhance_contrast(grayscale_image)
198
- if processed_image.size < self._expected_image_size:
199
- return False
200
-
201
- if self._prev_image.size == self._expected_image_size:
202
- change_area = self.compare_images(self._prev_image, processed_image)
203
- else:
204
- self._prev_image = processed_image
205
- return True
206
-
207
- lpm: float = 0.0
208
-
209
- if change_area > 0.0:
210
- # to capture even the smallest leaks, update the previous image
211
- # only when difference is found
212
- self._prev_image = processed_image
213
- wm_elapsed_seconds: float = time.time() - self._wm_start_seconds
214
- self._wm_start_seconds = time.time()
215
-
216
- # image change_area factor to consumed water in liters
217
- liters = change_area * self._calibration_factor
218
-
219
- # update cumulative water consumption
220
- self.total_liter += liters / 1000.0
221
-
222
- # scale liters to flow (liters per minute)
223
- lpm = liters / (wm_elapsed_seconds / 60.0)
224
-
225
- watermeter: dict[str, Union[float, str]] = {
226
- "location": self._location,
227
- "sensor": self.sensor_name,
228
- "total_liter": self.total_liter,
229
- "active_lpm": lpm,
230
- "ts": timestamp(),
231
- }
232
-
233
- msg = json.dumps(watermeter)
234
- self.publish(self._watermeter_topic, msg, qos=0, retain=False)
235
- return True
236
-
237
-
238
- class WaterMeterImgDiff(WebCamera):
239
- """WebCamera based optical watermeter. Needs a low-cost web camera and a spot
240
- light to illuminate the watermeter. Captures images with specified interval (the default
241
- is 1 minute) and computes a factor that represents the level of difference, 0.0 being no
242
- differences and 1.0 corresponding to the maximum difference (all pixels different with
243
- maximum contrast e.g. 0 vs 255).
244
- The more two consequtive images differ, the higher the water consumption. Cannot give any absolute water consumption
245
- measurements as liters, but suits well for leak detection purposes - the greater
246
- the difference the creater the water consumption.
247
-
248
- """
249
-
250
- _WATERMETER: str = "watermeter_imgdiff"
251
- _WATERMETER_ATTRS: list[str] = [
252
- "topic",
253
- "update_interval",
254
- "location",
255
- "camera",
256
- "save_images",
257
- ]
258
-
259
- _workerThreadId: str = WaterMeterThreadImgDiff.get_class_id()
260
- update_interval: float = 30
261
- topic = "watermeter"
262
- location = "home"
263
- camera: int = 0
264
- save_images: bool = True
265
-
266
- def __init__(self, name="watermeter_imgdiff") -> None:
267
- """Constructs system status automation object for acquiring and publishing
268
- system info e.g. available memory and CPU loads.
269
-
270
- Args:
271
- name (str, optional): name of the object.
272
- """
273
- super().__init__(name)
274
- self.worker: Optional[WaterMeterThreadImgDiff] = None
275
- self.watermeter_topic: str = self.make_topic_name(self.topic)
276
-
277
- @override
278
- def initialize(self) -> None:
279
- # let the super class to initialize database first so that we can read it
280
- super().initialize()
281
-
282
- # read the latest known value from
283
- last_value: dict[str, float] = self.read_last_value(
284
- "watermeter",
285
- {"sensor": self.name, "location": self.location},
286
- ["total_liter"],
287
- )
288
- worker: WaterMeterThreadImgDiff = cast(WaterMeterThreadImgDiff, self.worker)
289
- if "total_liter" in last_value:
290
- worker.total_liter = last_value["total_liter"]
291
- self.info(f"Total liters {worker.total_liter} read from the database")
292
- else:
293
- self.warning("no previous database value for total_liter found")
294
-
295
- @override
296
- def run(self) -> None:
297
- # create, initialize and start the asynchronous thread for acquiring forecast
298
-
299
- self.worker = cast(
300
- WaterMeterThreadImgDiff, self.instantiate(WaterMeterImgDiff._workerThreadId)
301
- )
302
- self.worker.sensor_name = self.name
303
-
304
- self.worker.init_watermeter_imgdiff(
305
- self.update_interval,
306
- self.location,
307
- self.camera,
308
- self.watermeter_topic,
309
- self.save_images,
310
- )
311
- super().run()
312
-
313
- @override
314
- def to_dict(self) -> dict[str, Any]:
315
- data = super().to_dict() # Call parent class method
316
- watermeter_data = {}
317
- for attr in self._WATERMETER_ATTRS:
318
- watermeter_data[attr] = getattr(self, attr)
319
- data[self._WATERMETER] = watermeter_data
320
- return data
321
-
322
- @override
323
- def from_dict(self, data: dict[str, Any]) -> None:
324
- super().from_dict(data) # Call parent class method
325
- if self._WATERMETER in data:
326
- watermeter_data = data[self._WATERMETER]
327
- for attr in self._WATERMETER_ATTRS:
328
- setattr(self, attr, watermeter_data.get(attr, None))
1
+ """Web camera based optical watermeter based on differences between subsequent frames.
2
+
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+ import cv2
11
+ import numpy as np
12
+ from typing import Any, Optional, Union, cast
13
+ from typing_extensions import override
14
+ from masterpiece.mqtt import Mqtt
15
+
16
+ from juham_core.timeutils import timestamp
17
+ from .webcamera import WebCameraThread, WebCamera
18
+
19
+
20
+ class WaterMeterThreadImgDiff(WebCameraThread):
21
+ """Asynchronous thread for capturing and processing images of web camera.
22
+ Uploads three images to sftp server for inspection: the original watermeter
23
+ image, the image holding differences between the last captured watermeter image,
24
+ and processed image with noise eliminated, and differences scaled up for maximum
25
+ image contrast: white pixels represent areas. The more white pixels, the bigger the
26
+ water consumption.
27
+ """
28
+
29
+ # class attributes
30
+ _watermeter_topic: str = ""
31
+ _expected_image_size: int = 640 * 480
32
+ _save_images: bool = True
33
+ _calibration_factor: float = 1000.0 # img diff to liters
34
+ _max_change_area: float = 0.1 # difference can't be greater than this
35
+ ftp_site: str = ""
36
+ ftp_user: str = ""
37
+ ftp_pw: str = ""
38
+
39
+ def __init__(self, client: Optional[Mqtt] = None):
40
+ """Construct with the given mqtt client.
41
+
42
+ Args:
43
+ client (object, optional): MQTT client. Defaults to None.
44
+ """
45
+ super().__init__(client)
46
+ self.sensor_name = "watermeter_imgdiff"
47
+ self.mqtt_client: Optional[Mqtt] = client
48
+ self.total_liter: float = 0.0
49
+ self.active_liter_lpm: float = 0.0
50
+ self._prev_image: np.ndarray = np.zeros((1, 1, 3), dtype=np.uint8)
51
+ self._prev_image_initialized = False
52
+ self._prev_time: float = 0.0
53
+
54
+ # total cumulative diff between two consequtive images
55
+ self._wm_start_seconds: float = 0.0
56
+
57
+ # temp filenames for saving the original and processed images, for debugging purposes
58
+ self._temp_filename1: str = ""
59
+ self._temp_filename2: str = ""
60
+
61
+ def init_watermeter_imgdiff(
62
+ self,
63
+ interval: float,
64
+ location: str,
65
+ camera: int,
66
+ topic: str,
67
+ save_images: bool,
68
+ ) -> None:
69
+ """Initialize the data acquisition thread
70
+
71
+ Args:
72
+ topic (str): mqtt topic to publish the acquired system info
73
+ interval (float): update interval in seconds
74
+ location (str): geographic location
75
+ camera(int) : ordinal specifying the camera to be used (0, 1)
76
+ save_images (bool) : true to enable saving of captured images, for debugging
77
+ """
78
+ super().init(interval, location, camera)
79
+ self._watermeter_topic = topic
80
+ self._save_images = save_images
81
+ # temp filenames for saving the original and processed images, for debugging purposes
82
+ tmpdir: str = tempfile.mkdtemp()
83
+ self._temp_filename1 = os.path.join(tmpdir, f"{self.sensor_name}_wm_1.png")
84
+ self._temp_filename2 = os.path.join(tmpdir, f"{self.sensor_name}_wm_2.png")
85
+
86
+ def upload_image(self, file: str, img: np.ndarray) -> None:
87
+ """Save the image to the given file and upload the file to the FTP server.
88
+
89
+ Args:
90
+ file (str): The filename where the image will be saved.
91
+ img (np.ndarray): The image to be saved and uploaded.
92
+ """
93
+ # Save the image to the specified file
94
+ cv2.imwrite(file, img)
95
+
96
+ try:
97
+ # Upload the file to the FTP server
98
+ self.upload_file(file)
99
+
100
+ # If the upload is successful, remove the file
101
+ os.remove(file)
102
+
103
+ except Exception as e:
104
+ # Handle any errors that occurred during upload
105
+ self.error(f"Error during file upload: {e}")
106
+
107
+ def compare_images(
108
+ self,
109
+ np_prev: np.ndarray,
110
+ np_current: np.ndarray,
111
+ threshold: int = 20,
112
+ ) -> float:
113
+ """
114
+ Compares two images and returns a float value representing the level of differences.
115
+
116
+ Parameters:
117
+ np_prev (np.ndarray): The previous image.
118
+ np_current (np.ndarray): The current image.
119
+ threshold (int): Threshold value to filter noise in the difference image.
120
+
121
+ Returns:
122
+ float: A value between 0.0 and 1.0 indicating the level of difference.
123
+ 0.0 means identical, 1.0 means maximally different.
124
+ """
125
+ # Ensure both images are the same size and type
126
+ if np_prev.shape != np_current.shape:
127
+ self.error("Images have different shapes or dimensions.")
128
+ return -1.0 # Error value for different shapes
129
+
130
+ # Step 1: Calculate the absolute difference between the two images
131
+ diff_image = cv2.absdiff(np_prev, np_current)
132
+
133
+ # Step 2: Filter small differences (noise) using a binary threshold
134
+ _, binary_diff = cv2.threshold(diff_image, threshold, 255, cv2.THRESH_BINARY)
135
+
136
+ # Step 3: Calculate the proportion of changed pixels
137
+ change_area: float = np.count_nonzero(binary_diff) / binary_diff.size
138
+
139
+ if change_area > 0.0:
140
+ print(f"Waterflow detected {change_area}, uploading")
141
+ self.upload_images(np_current, binary_diff)
142
+
143
+ # Return a value between 0.0 (identical) and 1.0 (maximally different)
144
+ return min(max(change_area, 0.0), 1.0)
145
+
146
+ @override
147
+ def update_interval(self) -> float:
148
+ return self._interval
149
+
150
+ def upload_file(self, filename: str) -> None:
151
+ """Upload the given filename to the ftp server, if the server is configured.
152
+
153
+ Args:
154
+ filename (str): _description_
155
+ """
156
+ if len(self.ftp_site) > 0 and len(self.ftp_user) > 0 and len(self.ftp_pw) > 0:
157
+
158
+ # Build the curl command for uploading the file
159
+ curl_command = [
160
+ "curl",
161
+ f"-u{self.ftp_user}:{self.ftp_pw}",
162
+ "--retry",
163
+ "3",
164
+ "--retry-delay",
165
+ "5",
166
+ "-T",
167
+ filename,
168
+ self.ftp_site,
169
+ ]
170
+
171
+ # Execute the curl command
172
+ try:
173
+ subprocess.run(curl_command, check=True)
174
+ except subprocess.CalledProcessError as e:
175
+ self.error(f"Error during image upload: {e}")
176
+
177
+ def upload_images(self, np_watermeter: np.ndarray, np_diff: np.ndarray) -> None:
178
+ """Upload captured grayscale watermeter image, and the diff image to ftp site
179
+
180
+ Parameters:
181
+ np_watermeter (np.ndarray): Watermeter image in grayscale
182
+ np_diff (np.ndarray): The diff image reflecting consumed water
183
+ """
184
+ self.upload_image(self._temp_filename1, np_watermeter)
185
+ self.upload_image(self._temp_filename2, np_diff)
186
+
187
+ @override
188
+ def update(self) -> bool:
189
+ change_area: float = -1
190
+ captured_image = self.capture_image()
191
+ if captured_image.size < self._expected_image_size:
192
+ return False
193
+ grayscale_image = self.process_image(captured_image)
194
+ if grayscale_image.size < self._expected_image_size:
195
+ return False
196
+ processed_image = self.enhance_contrast(grayscale_image)
197
+ if processed_image.size < self._expected_image_size:
198
+ return False
199
+
200
+ if self._prev_image.size == self._expected_image_size:
201
+ change_area = self.compare_images(self._prev_image, processed_image)
202
+ else:
203
+ self._prev_image = processed_image
204
+ return True
205
+
206
+ lpm: float = 0.0
207
+
208
+ if change_area > 0.0:
209
+ # to capture even the smallest leaks, update the previous image
210
+ # only when difference is found
211
+ self._prev_image = processed_image
212
+ wm_elapsed_seconds: float = time.time() - self._wm_start_seconds
213
+ self._wm_start_seconds = time.time()
214
+
215
+ # image change_area factor to consumed water in liters
216
+ liters = change_area * self._calibration_factor
217
+
218
+ # update cumulative water consumption
219
+ self.total_liter += liters / 1000.0
220
+
221
+ # scale liters to flow (liters per minute)
222
+ lpm = liters / (wm_elapsed_seconds / 60.0)
223
+
224
+ watermeter: dict[str, Union[float, str]] = {
225
+ "location": self._location,
226
+ "sensor": self.sensor_name,
227
+ "total_liter": self.total_liter,
228
+ "active_lpm": lpm,
229
+ "ts": timestamp(),
230
+ }
231
+
232
+ msg = json.dumps(watermeter)
233
+ self.publish(self._watermeter_topic, msg, qos=0, retain=False)
234
+ return True
235
+
236
+
237
+ class WaterMeterImgDiff(WebCamera):
238
+ """WebCamera based optical watermeter. Needs a low-cost web camera and a spot
239
+ light to illuminate the watermeter. Captures images with specified interval (the default
240
+ is 1 minute) and computes a factor that represents the level of difference, 0.0 being no
241
+ differences and 1.0 corresponding to the maximum difference (all pixels different with
242
+ maximum contrast e.g. 0 vs 255).
243
+ The more two consequtive images differ, the higher the water consumption. Cannot give any absolute water consumption
244
+ measurements as liters, but suits well for leak detection purposes - the greater
245
+ the difference the creater the water consumption.
246
+
247
+ """
248
+
249
+ _WATERMETER: str = "watermeter_imgdiff"
250
+ _WATERMETER_ATTRS: list[str] = [
251
+ "topic",
252
+ "update_interval",
253
+ "location",
254
+ "camera",
255
+ "save_images",
256
+ ]
257
+
258
+ _workerThreadId: str = WaterMeterThreadImgDiff.get_class_id()
259
+ update_interval: float = 30
260
+ topic = "watermeter"
261
+ location = "home"
262
+ camera: int = 0
263
+ save_images: bool = True
264
+
265
+ def __init__(self, name="watermeter_imgdiff") -> None:
266
+ """Constructs system status automation object for acquiring and publishing
267
+ system info e.g. available memory and CPU loads.
268
+
269
+ Args:
270
+ name (str, optional): name of the object.
271
+ """
272
+ super().__init__(name)
273
+ self.worker: Optional[WaterMeterThreadImgDiff] = None
274
+ self.watermeter_topic: str = self.make_topic_name(self.topic)
275
+
276
+ @override
277
+ def initialize(self) -> None:
278
+ # let the super class to initialize database first so that we can read it
279
+ super().initialize()
280
+
281
+ # read the latest known value from
282
+ last_value: dict[str, float] = self.read_last_value(
283
+ "watermeter",
284
+ {"sensor": self.name, "location": self.location},
285
+ ["total_liter"],
286
+ )
287
+ worker: WaterMeterThreadImgDiff = cast(WaterMeterThreadImgDiff, self.worker)
288
+ if "total_liter" in last_value:
289
+ worker.total_liter = last_value["total_liter"]
290
+ self.info(f"Total liters {worker.total_liter} read from the database")
291
+ else:
292
+ self.warning("no previous database value for total_liter found")
293
+
294
+ @override
295
+ def run(self) -> None:
296
+ # create, initialize and start the asynchronous thread for acquiring forecast
297
+
298
+ self.worker = cast(
299
+ WaterMeterThreadImgDiff, self.instantiate(WaterMeterImgDiff._workerThreadId)
300
+ )
301
+ self.worker.sensor_name = self.name
302
+
303
+ self.worker.init_watermeter_imgdiff(
304
+ self.update_interval,
305
+ self.location,
306
+ self.camera,
307
+ self.watermeter_topic,
308
+ self.save_images,
309
+ )
310
+ super().run()
311
+
312
+ @override
313
+ def to_dict(self) -> dict[str, Any]:
314
+ data = super().to_dict() # Call parent class method
315
+ watermeter_data = {}
316
+ for attr in self._WATERMETER_ATTRS:
317
+ watermeter_data[attr] = getattr(self, attr)
318
+ data[self._WATERMETER] = watermeter_data
319
+ return data
320
+
321
+ @override
322
+ def from_dict(self, data: dict[str, Any]) -> None:
323
+ super().from_dict(data) # Call parent class method
324
+ if self._WATERMETER in data:
325
+ watermeter_data = data[self._WATERMETER]
326
+ for attr in self._WATERMETER_ATTRS:
327
+ setattr(self, attr, watermeter_data.get(attr, None))