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