gazpar2haws 0.2.1__py3-none-any.whl → 0.3.0b16__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.
gazpar2haws/gazpar.py CHANGED
@@ -1,12 +1,23 @@
1
1
  import logging
2
2
  import traceback
3
- from datetime import datetime, timedelta
4
- from typing import Any
3
+ from datetime import date, datetime, timedelta
4
+ from typing import Optional
5
5
 
6
6
  import pygazpar # type: ignore
7
7
  import pytz
8
+ from pygazpar.datasource import MeterReadings # type: ignore
8
9
 
10
+ from gazpar2haws.date_array import DateArray
9
11
  from gazpar2haws.haws import HomeAssistantWS, HomeAssistantWSException
12
+ from gazpar2haws.model import (
13
+ ConsumptionQuantityArray,
14
+ Device,
15
+ PriceUnit,
16
+ Pricing,
17
+ QuantityUnit,
18
+ TimeUnit,
19
+ )
20
+ from gazpar2haws.pricer import Pricer
10
21
 
11
22
  Logger = logging.getLogger(__name__)
12
23
 
@@ -15,80 +26,51 @@ Logger = logging.getLogger(__name__)
15
26
  class Gazpar:
16
27
 
17
28
  # ----------------------------------
18
- def __init__(self, config: dict[str, Any], homeassistant: HomeAssistantWS):
29
+ def __init__(
30
+ self,
31
+ device_config: Device,
32
+ pricing_config: Optional[Pricing],
33
+ homeassistant: HomeAssistantWS,
34
+ ):
19
35
 
20
36
  self._homeassistant = homeassistant
37
+ self._grdf_config = device_config
38
+ self._pricing_config = pricing_config
21
39
 
22
40
  # GrDF configuration: name
23
- if config.get("name") is None:
24
- raise ValueError("Configuration parameter 'grdf.devices[].name' is missing")
25
- self._name = config.get("name")
41
+ self._name = device_config.name
26
42
 
27
43
  # GrDF configuration: data source
28
- self._data_source = (
29
- config.get("data_source") if config.get("data_source") else "json"
30
- )
44
+ self._data_source = device_config.data_source
31
45
 
32
46
  # GrDF configuration: username
33
- if self._data_source != "test" and config.get("username") is None:
34
- raise ValueError(
35
- "Configuration parameter 'grdf.devices[].username' is missing"
36
- )
37
- self._username = config.get("username")
47
+ self._username = device_config.username
38
48
 
39
49
  # GrDF configuration: password
40
- if self._data_source != "test" and config.get("password") is None:
41
- raise ValueError(
42
- "Configuration parameter 'grdf.devices[].password' is missing"
43
- )
44
- self._password = config.get("password")
50
+ self._password = device_config.password.get_secret_value() if device_config.password is not None else None
45
51
 
46
52
  # GrDF configuration: pce_identifier
47
- if self._data_source != "test" and config.get("pce_identifier") is None:
48
- raise ValueError(
49
- "Configuration parameter 'grdf.devices[].pce_identifier' is missing"
50
- )
51
- self._pce_identifier = str(config.get("pce_identifier"))
53
+ self._pce_identifier = (
54
+ device_config.pce_identifier.get_secret_value() if device_config.pce_identifier is not None else None
55
+ )
52
56
 
53
57
  # GrDF configuration: tmp_dir
54
- self._tmp_dir = config.get("tmp_dir") if config.get("tmp_dir") else "/tmp"
58
+ self._tmp_dir = device_config.tmp_dir
55
59
 
56
60
  # GrDF configuration: last_days
57
- if config.get("last_days") is None:
58
- raise ValueError(
59
- "Configuration parameter 'grdf.devices[].last_days' is missing"
60
- )
61
- self._last_days = int(str(config.get("last_days")))
61
+ self._last_days = device_config.last_days
62
62
 
63
63
  # GrDF configuration: timezone
64
- if config.get("timezone") is None:
65
- raise ValueError(
66
- "Configuration parameter 'grdf.devices[].timezone' is missing"
67
- )
68
- self._timezone = str(config.get("timezone"))
64
+ self._timezone = device_config.timezone
69
65
 
70
66
  # GrDF configuration: reset
71
- if config.get("reset") is None:
72
- raise ValueError(
73
- "Configuration parameter 'grdf.devices[].reset' is missing"
74
- )
75
- self._reset = bool(config.get("reset"))
67
+ self._reset = device_config.reset
76
68
 
77
69
  # As of date: YYYY-MM-DD
78
- as_of_date = config.get("as_of_date")
79
- if self._data_source is not None and str(self._data_source).lower() == "test":
80
- self._as_of_date = (
81
- datetime.now(tz=pytz.timezone(self._timezone))
82
- if as_of_date is None
83
- else datetime.strptime(as_of_date, "%Y-%m-%d")
84
- )
85
- else:
86
- self._as_of_date = datetime.now(tz=pytz.timezone(self._timezone))
70
+ self._as_of_date = device_config.as_of_date
87
71
 
88
72
  # Set the timezone
89
- timezone = pytz.timezone(self._timezone)
90
- if self._as_of_date.tzinfo is None:
91
- self._as_of_date = timezone.localize(self._as_of_date)
73
+ self._timezone = device_config.timezone
92
74
 
93
75
  # ----------------------------------
94
76
  def name(self):
@@ -98,41 +80,108 @@ class Gazpar:
98
80
  # Publish Gaspar data to Home Assistant WS
99
81
  async def publish(self):
100
82
 
101
- # Volume and energy sensor names.
83
+ # Volume, energy and cost sensor names.
102
84
  volume_sensor_name = f"sensor.{self._name}_volume"
103
85
  energy_sensor_name = f"sensor.{self._name}_energy"
86
+ cost_sensor_name = f"sensor.{self._name}_cost"
104
87
 
105
88
  # Eventually reset the sensor in Home Assistant
106
89
  if self._reset:
107
90
  try:
108
- await self._homeassistant.clear_statistics(
109
- [volume_sensor_name, energy_sensor_name]
110
- )
91
+ await self._homeassistant.clear_statistics([volume_sensor_name, energy_sensor_name])
111
92
  except Exception:
112
- Logger.warning(
113
- f"Error while resetting the sensor in Home Assistant: {traceback.format_exc()}"
114
- )
93
+ Logger.warning(f"Error while resetting the sensor in Home Assistant: {traceback.format_exc()}")
115
94
  raise
116
95
 
117
- # Publish volume sensor
118
- await self._publish_entity(
119
- volume_sensor_name, pygazpar.PropertyName.VOLUME.value, "m³"
120
- )
121
- await self._publish_entity(
122
- energy_sensor_name, pygazpar.PropertyName.ENERGY.value, "kWh"
96
+ last_date_and_value_by_sensor = dict[str, tuple[date, float]]()
97
+
98
+ last_date_and_value_by_sensor[volume_sensor_name] = await self.find_last_date_and_value(volume_sensor_name)
99
+ last_date_and_value_by_sensor[energy_sensor_name] = await self.find_last_date_and_value(energy_sensor_name)
100
+ last_date_and_value_by_sensor[cost_sensor_name] = await self.find_last_date_and_value(cost_sensor_name)
101
+
102
+ # Compute the start date as the minimum of the last dates plus one day
103
+ start_date = min(
104
+ min(v[0] for v in last_date_and_value_by_sensor.values()) + timedelta(days=1), self._as_of_date
123
105
  )
124
106
 
125
- # ----------------------------------
126
- # Publish a sensor to Home Assistant
127
- async def _publish_entity(
128
- self, entity_id: str, property_name: str, unit_of_measurement: str
129
- ):
107
+ # The end date is the as of date
108
+ end_date = self._as_of_date
130
109
 
131
- # Find last date, days and value of the entity.
132
- last_date, last_days, last_value = await self._find_last_date_days_value(
133
- entity_id
110
+ # Fetch the data from GrDF and publish it to Home Assistant
111
+ daily_history = self.fetch_daily_gazpar_history(start_date, end_date)
112
+
113
+ # Extract the volume from the daily history
114
+ volume_array = self.extract_property_from_daily_gazpar_history(
115
+ daily_history,
116
+ pygazpar.PropertyName.VOLUME.value,
117
+ last_date_and_value_by_sensor[volume_sensor_name][0],
118
+ end_date,
134
119
  )
135
120
 
121
+ # Extract the energy from the daily history
122
+ energy_array = self.extract_property_from_daily_gazpar_history(
123
+ daily_history,
124
+ pygazpar.PropertyName.ENERGY.value,
125
+ last_date_and_value_by_sensor[energy_sensor_name][0],
126
+ end_date,
127
+ )
128
+
129
+ # Publish the volume and energy to Home Assistant
130
+ if volume_array is not None:
131
+ await self.publish_date_array(
132
+ volume_sensor_name,
133
+ "m³",
134
+ volume_array,
135
+ last_date_and_value_by_sensor[volume_sensor_name][1],
136
+ )
137
+ else:
138
+ Logger.info("No volume data to publish")
139
+
140
+ if energy_array is not None:
141
+ await self.publish_date_array(
142
+ energy_sensor_name,
143
+ "kWh",
144
+ energy_array,
145
+ last_date_and_value_by_sensor[energy_sensor_name][1],
146
+ )
147
+ else:
148
+ Logger.info("No energy data to publish")
149
+
150
+ if self._pricing_config is None:
151
+ Logger.info("No pricing configuration provided")
152
+ return
153
+
154
+ # Compute the cost from the energy
155
+ if energy_array is not None:
156
+ pricer = Pricer(self._pricing_config)
157
+
158
+ quantities = ConsumptionQuantityArray(
159
+ start_date=last_date_and_value_by_sensor[energy_sensor_name][0],
160
+ end_date=end_date,
161
+ value_unit=QuantityUnit.KWH,
162
+ base_unit=TimeUnit.DAY,
163
+ value_array=energy_array,
164
+ )
165
+
166
+ cost_array = pricer.compute(quantities, PriceUnit.EURO)
167
+ else:
168
+ cost_array = None
169
+
170
+ # Publish the cost to Home Assistant
171
+ if cost_array is not None:
172
+ await self.publish_date_array(
173
+ cost_sensor_name,
174
+ cost_array.value_unit,
175
+ cost_array.value_array,
176
+ last_date_and_value_by_sensor[cost_sensor_name][1],
177
+ )
178
+ else:
179
+ Logger.info("No cost data to publish")
180
+
181
+ # ----------------------------------
182
+ # Fetch daily Gazpar history.
183
+ def fetch_daily_gazpar_history(self, start_date: date, end_date: date) -> MeterReadings:
184
+
136
185
  # Instantiate the right data source.
137
186
  data_source = self._create_data_source()
138
187
 
@@ -140,48 +189,77 @@ class Gazpar:
140
189
  client = pygazpar.Client(data_source)
141
190
 
142
191
  try:
143
- data = client.loadSince(
192
+ history = client.loadDateRange(
144
193
  pceIdentifier=self._pce_identifier,
145
- lastNDays=last_days,
194
+ startDate=start_date,
195
+ endDate=end_date,
146
196
  frequencies=[pygazpar.Frequency.DAILY],
147
197
  )
198
+ res = history[pygazpar.Frequency.DAILY.value]
148
199
  except Exception: # pylint: disable=broad-except
149
- Logger.warning(
150
- f"Error while fetching data from GrDF: {traceback.format_exc()}"
151
- )
152
- data = {}
200
+ Logger.warning(f"Error while fetching data from GrDF: {traceback.format_exc()}")
201
+ res = MeterReadings()
153
202
 
154
- # Timezone
155
- timezone = pytz.timezone(self._timezone)
203
+ return res
156
204
 
157
- # Compute and fill statistics.
158
- daily = data.get(pygazpar.Frequency.DAILY.value)
159
- statistics = []
160
- total = last_value
161
- for reading in daily:
205
+ # ----------------------------------
206
+ # Extract a given property from the daily Gazpar history and return a DateArray.
207
+ def extract_property_from_daily_gazpar_history(
208
+ self,
209
+ readings: MeterReadings,
210
+ property_name: str,
211
+ start_date: date,
212
+ end_date: date,
213
+ ) -> Optional[DateArray]:
214
+
215
+ # Fill the quantity array.
216
+ res: Optional[DateArray] = None
217
+
218
+ for reading in readings:
162
219
  # Parse date format DD/MM/YYYY into datetime.
163
- date = datetime.strptime(
164
- reading[pygazpar.PropertyName.TIME_PERIOD.value], "%d/%m/%Y"
165
- )
220
+ reading_date = datetime.strptime(reading[pygazpar.PropertyName.TIME_PERIOD.value], "%d/%m/%Y").date()
166
221
 
167
- # Set the timezone
168
- date = timezone.localize(date)
222
+ # Skip all readings before the start date.
223
+ if reading_date < start_date:
224
+ # Logger.debug(f"Skip date: {reading_date} < {start_date}")
225
+ continue
169
226
 
170
- # Skip all readings before the last statistic date.
171
- if date <= last_date:
172
- Logger.debug(f"Skip date: {date} <= {last_date}")
227
+ # Skip all readings after the end date.
228
+ if reading_date > end_date:
229
+ # Logger.debug(f"Skip date: {reading_date} > {end_date}")
173
230
  continue
174
231
 
175
- # Compute the total volume and energy
232
+ # Fill the quantity array.
176
233
  if reading[property_name] is not None:
177
- total += reading[property_name]
178
- else:
179
- Logger.warning(
180
- f"Missing property {property_name} for date {date}. Skipping..."
181
- )
182
- continue
234
+ if res is None:
235
+ res = DateArray(start_date=start_date, end_date=end_date)
236
+ res[reading_date] = reading[property_name]
237
+
238
+ return res
239
+
240
+ # ----------------------------------
241
+ # Push a date array to Home Assistant.
242
+ async def publish_date_array(
243
+ self,
244
+ entity_id: str,
245
+ unit_of_measurement: str,
246
+ date_array: DateArray,
247
+ initial_value: float,
248
+ ):
183
249
 
184
- statistics.append({"start": date.isoformat(), "state": total, "sum": total})
250
+ # Compute the cumulative sum of the values.
251
+ total_array = date_array.cumsum() + initial_value
252
+
253
+ # Timezone
254
+ timezone = pytz.timezone(self._timezone)
255
+
256
+ # Fill the statistics.
257
+ statistics = []
258
+ for dt, total in total_array:
259
+ # Set the timezone
260
+ date_time = datetime.combine(dt, datetime.min.time())
261
+ date_time = timezone.localize(date_time)
262
+ statistics.append({"start": date_time.isoformat(), "state": total, "sum": total})
185
263
 
186
264
  # Publish statistics to Home Assistant
187
265
  try:
@@ -189,9 +267,7 @@ class Gazpar:
189
267
  entity_id, "recorder", "gazpar2haws", unit_of_measurement, statistics
190
268
  )
191
269
  except Exception:
192
- Logger.warning(
193
- f"Error while importing statistics to Home Assistant: {traceback.format_exc()}"
194
- )
270
+ Logger.warning(f"Error while importing statistics to Home Assistant: {traceback.format_exc()}")
195
271
  raise
196
272
 
197
273
  # ----------------------------------
@@ -209,36 +285,31 @@ class Gazpar:
209
285
  tmpDirectory=self._tmp_dir,
210
286
  )
211
287
 
212
- return pygazpar.JsonWebDataSource(
213
- username=self._username, password=self._password
214
- )
288
+ return pygazpar.JsonWebDataSource(username=self._username, password=self._password)
215
289
 
216
290
  # ----------------------------------
217
- # Find last date, days and value of the entity.
218
- async def _find_last_date_days_value(
219
- self, entity_id: str
220
- ) -> tuple[datetime, int, float]:
291
+ # Find last date, value of the entity.
292
+ async def find_last_date_and_value(self, entity_id: str) -> tuple[date, float]:
221
293
 
222
294
  # Check the existence of the sensor in Home Assistant
223
295
  try:
224
- exists_statistic_id = await self._homeassistant.exists_statistic_id(
225
- entity_id, "sum"
226
- )
296
+ exists_statistic_id = await self._homeassistant.exists_statistic_id(entity_id, "sum")
227
297
  except Exception:
228
298
  Logger.warning(
229
- f"Error while checking the existence of the sensor in Home Assistant: {traceback.format_exc()}"
299
+ f"Error while checking the existence of the entity '{entity_id}' in Home Assistant: {traceback.format_exc()}"
230
300
  )
231
301
  raise
232
302
 
233
303
  if exists_statistic_id:
234
304
  # Get the last statistic from Home Assistant
235
305
  try:
236
- last_statistic = await self._homeassistant.get_last_statistic(
237
- entity_id, self._as_of_date, self._last_days
238
- )
306
+ as_of_date = datetime.combine(self._as_of_date, datetime.min.time())
307
+ as_of_date = pytz.timezone(self._timezone).localize(as_of_date)
308
+
309
+ last_statistic = await self._homeassistant.get_last_statistic(entity_id, as_of_date, self._last_days)
239
310
  except HomeAssistantWSException:
240
311
  Logger.warning(
241
- f"Error while fetching last statistics from Home Assistant: {traceback.format_exc()}"
312
+ f"Error while fetching last statistics of the entity '{entity_id}' from Home Assistant: {traceback.format_exc()}"
242
313
  )
243
314
 
244
315
  if last_statistic:
@@ -246,35 +317,25 @@ class Gazpar:
246
317
  last_date = datetime.fromtimestamp(
247
318
  int(str(last_statistic.get("start"))) / 1000,
248
319
  tz=pytz.timezone(self._timezone),
249
- )
250
-
251
- # Compute the number of days since the last statistics
252
- last_days = (self._as_of_date - last_date).days
320
+ ).date()
253
321
 
254
322
  # Get the last meter value
255
323
  last_value = float(str(last_statistic.get("sum")))
256
324
 
257
- Logger.debug(
258
- f"Last date: {last_date}, last days: {last_days}, last value: {last_value}"
259
- )
325
+ Logger.debug(f"Entity '{entity_id}' => Last date: {last_date}, last value: {last_value}")
260
326
 
261
- return last_date, last_days, last_value
327
+ return last_date, last_value
262
328
 
263
- Logger.debug(f"No statistics found for the existing sensor {entity_id}.")
329
+ Logger.debug(f"Entity '{entity_id}' => No statistics found.")
264
330
  else:
265
- Logger.debug(f"Sensor {entity_id} does not exist in Home Assistant.")
266
-
267
- # If the sensor does not exist in Home Assistant, fetch the last days defined in the configuration
268
- last_days = self._last_days
331
+ Logger.debug(f"Entity '{entity_id}' does not exist in Home Assistant.")
269
332
 
270
333
  # Compute the corresponding last_date
271
- last_date = self._as_of_date - timedelta(days=last_days)
334
+ last_date = self._as_of_date - timedelta(days=self._last_days)
272
335
 
273
336
  # If no statistic, the last value is initialized to zero
274
337
  last_value = 0
275
338
 
276
- Logger.debug(
277
- f"Last date: {last_date}, last days: {last_days}, last value: {last_value}"
278
- )
339
+ Logger.debug(f"Entity '{entity_id}' => Last date: {last_date}, last value: {last_value}")
279
340
 
280
- return last_date, last_days, last_value
341
+ return last_date, last_value
gazpar2haws/haws.py CHANGED
@@ -15,7 +15,7 @@ class HomeAssistantWSException(Exception):
15
15
  # ----------------------------------
16
16
  class HomeAssistantWS:
17
17
  # ----------------------------------
18
- def __init__(self, host: str, port: str, endpoint: str, token: str):
18
+ def __init__(self, host: str, port: int, endpoint: str, token: str):
19
19
  self._host = host
20
20
  self._port = port
21
21
  self._endpoint = endpoint
@@ -92,9 +92,7 @@ class HomeAssistantWS:
92
92
  raise HomeAssistantWSException(f"Invalid response message: {response_data}")
93
93
 
94
94
  if not response_data.get("success"):
95
- raise HomeAssistantWSException(
96
- f"Request failed: {response_data.get('error')}"
97
- )
95
+ raise HomeAssistantWSException(f"Request failed: {response_data.get('error')}")
98
96
 
99
97
  return response_data.get("result")
100
98
 
@@ -122,17 +120,13 @@ class HomeAssistantWS:
122
120
  return response
123
121
 
124
122
  # ----------------------------------
125
- async def exists_statistic_id(
126
- self, entity_id: str, statistic_type: str | None = None
127
- ) -> bool:
123
+ async def exists_statistic_id(self, entity_id: str, statistic_type: str | None = None) -> bool:
128
124
 
129
125
  Logger.debug(f"Checking if {entity_id} exists...")
130
126
 
131
127
  statistic_ids = await self.list_statistic_ids(statistic_type)
132
128
 
133
- entity_ids = [
134
- statistic_id.get("statistic_id") for statistic_id in statistic_ids
135
- ]
129
+ entity_ids = [statistic_id.get("statistic_id") for statistic_id in statistic_ids]
136
130
 
137
131
  exists_statistic = entity_id in entity_ids
138
132
 
@@ -141,13 +135,9 @@ class HomeAssistantWS:
141
135
  return exists_statistic
142
136
 
143
137
  # ----------------------------------
144
- async def statistics_during_period(
145
- self, entity_ids: list[str], start_time: datetime, end_time: datetime
146
- ) -> dict:
138
+ async def statistics_during_period(self, entity_ids: list[str], start_time: datetime, end_time: datetime) -> dict:
147
139
 
148
- Logger.debug(
149
- f"Getting {entity_ids} statistics during period from {start_time} to {end_time}..."
150
- )
140
+ Logger.debug(f"Getting {entity_ids} statistics during period from {start_time} to {end_time}...")
151
141
 
152
142
  # Subscribe to statistics
153
143
  statistics_message = {
@@ -166,16 +156,12 @@ class HomeAssistantWS:
166
156
  f"Invalid statistics_during_period response type: got {type(response)} instead of dict"
167
157
  )
168
158
 
169
- Logger.debug(
170
- f"Received {entity_ids} statistics during period from {start_time} to {end_time}"
171
- )
159
+ Logger.debug(f"Received {entity_ids} statistics during period from {start_time} to {end_time}")
172
160
 
173
161
  return response
174
162
 
175
163
  # ----------------------------------
176
- async def get_last_statistic(
177
- self, entity_id: str, as_of_date: datetime, depth_days: int
178
- ) -> dict:
164
+ async def get_last_statistic(self, entity_id: str, as_of_date: datetime, depth_days: int) -> dict:
179
165
 
180
166
  Logger.debug(f"Getting last statistic for {entity_id}...")
181
167
 
@@ -201,9 +187,7 @@ class HomeAssistantWS:
201
187
  statistics: list[dict],
202
188
  ):
203
189
 
204
- Logger.debug(
205
- f"Importing {len(statistics)} statistics for {entity_id} from {source}..."
206
- )
190
+ Logger.debug(f"Importing {len(statistics)} statistics for {entity_id} from {source}...")
207
191
 
208
192
  if len(statistics) == 0:
209
193
  Logger.debug("No statistics to import")
@@ -225,9 +209,7 @@ class HomeAssistantWS:
225
209
 
226
210
  await self.send_message(import_statistics_message)
227
211
 
228
- Logger.debug(
229
- f"Imported {len(statistics)} statistics for {entity_id} from {source}"
230
- )
212
+ Logger.debug(f"Imported {len(statistics)} statistics for {entity_id} from {source}")
231
213
 
232
214
  # ----------------------------------
233
215
  async def clear_statistics(self, entity_ids: list[str]):