Skip to content

Commit 414e23a

Browse files
committed
Add supports for OpenPAYGO Metrics
1 parent f3b82c4 commit 414e23a

5 files changed

Lines changed: 240 additions & 49 deletions

File tree

README.md

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,14 @@ metrics_request = MetricsRequestHandler(
192192
auth_method=AuthMethod.RECURSIVE_DATA_AUTH
193193
)
194194

195-
metrics_requestset_timestamp(1611583070)
196-
metrics_requestset_data({
195+
metrics_request.set_timestamp(1611583070)
196+
metrics_request.set_data({
197197
"token_count": 3,
198198
"tampered": False,
199199
"firmware_version": "1.2.3"
200200
})
201201
# Here we assume that the data we send is separated by 60 seconds as per the data format
202-
metrics_requestset_historical_data([
202+
metrics_request.set_historical_data([
203203
{
204204
"panel_voltage": 12.31,
205205
"battery_voltage": 12.32,
@@ -213,7 +213,7 @@ metrics_requestset_historical_data([
213213
"battery_current": -1.21,
214214
}
215215
])
216-
payload = metrics_requestget_condensed_request_payload()
216+
payload = metrics_request.get_condensed_request_payload()
217217

218218
# We can now proceed to send the payload to the URL
219219
# It looks something like `{"sn":"aaa111222","df":1234,"ts":1611583070,"d":[3,false,"1.2.3"],"hd":[[12.31,12.32,1.23,-1.23],[12.3,12.31,1.22,-1.21]],"a":"raa5cb1fda302cf94e"}`
@@ -232,39 +232,44 @@ You can use the `MetricsResponseHandler` object to process your OpenPAYGO Metric
232232
- `metrics_payload` (required): The OpenPAYGO Metrics payload, as a string containing the JSON payload.
233233
- `secret_key` (optional): The secret key provided as a string containing 32 hexadecimal characters (e.g. `dac86b1a29ab82edc5fbbc41ec9530f6`)
234234
- `data_format` (optional): The data format, provided as dictionnary matching the data format object specifications.
235+
- `last_request_count` (optional): The request count of the last valid request (used for avoiding request replay)
236+
- `last_request_timestamp` (optional): The timestamp of the last valid request (used for avoiding request replay)
235237

236238
It provides the following methods:
237239
- `get_device_serial()`: Returns the serial number of the device as a string.
238-
- `set_device_parameters(secret_key, data_format)`: Used to set the device data required for proper processing of the request in the handler if it was not set initially, which is often the case as the serial number is usually required to fetch that data. It will return `ValueError` if either of the parameters is invalid.
239-
- `is_auth_valid()`: Returns `true` if the authentication provided is valid or `false` if not.
240+
- `set_device_parameters(secret_key, data_format, last_request_count, last_request_timestamp)`: Used to set the device data required for proper processing of the request in the handler if it was not set initially, which is often the case as the serial number is usually required to fetch that data. It will return `ValueError` if either of the parameters is invalid.
241+
- `is_auth_valid()`: Returns `true` if the authentication provided is valid or `false` if not. Note that it checks both that the signature is valid and that the `request_count` or `timestamp` are more recent than the one provided in the device parameters.
240242
- `get_simple_metrics()`: Returns the metrics provided in the simple expanded format. It will also convert relative timestamps into explicit timestamps for easier processing.
241243
- `expects_token_answer()`: Return `true` if the payload requested tokens in the answer. You can set the tokens to be returned by calling `add_tokens_to_answer(token_list)` with `token_list` being a list of token strings.
242244
- `expects_time_answer()`: Return `true` if the payload requested either relative time or absolute time in the answer. You can set the time to be returned by calling `add_time_to_answer(target_datetime)` with `target_datetime` being a datetime object. The function will automatically provide it in the correct format based on the request.
243245
- `add_settings_to_answer(settings_dict)`: Will add the provided settings dictionnary to the answer.
244246
- `add_extra_data_to_answer(extra_data_dict)`: Will add the provided extra data dictionnary to the answer.
245-
- `get_answer()`: Will return the answer as a string based on the request and the data added to answer, it will automatically handle the authentication and fomatting.
247+
- `add_new_base_url_to_answer(new_base_url)`: Will tell the device to change the base URL to send the data to.
248+
- `get_answer_payload()`: Will return the answer as a string based on the request and the data added to answer, it will automatically handle the authentication and fomatting.
246249

247250

248251
**Example - Full Request flow from server side:**
249252

250253
```python
251-
from openpaygo import MetricsHandler
252-
from my_db_service import get_device, store_metric
254+
from openpaygo import MetricsResponseHandler
255+
from my_db_service import get_device, get_data_format, store_metric
253256

254257

255258
@app.route('/dd')
256259
def device_data():
257260
# We load the metrics
258261
try:
259-
metrics = MetricsHandler(request.data)
262+
metrics = MetricsResponseHandler(request.data)
260263
except ValueError as e:
261264
return {'error': 'Invalid data format'}, 400
262265
# We get the serial number and load the device data from our storage
263266
device = get_device(serial=metrics.get_device_serial())
267+
# We get the data format if needed from our storage
268+
data_format = get_data_format(id=metrics.get_data_format_id()) if not metrics.data_format_available() else None
264269
# We set the device parameters in the metrics handler
265270
metrics.set_device_parameters(
266271
secret_key=device.secret_key,
267-
data_format=device.data_format
272+
data_format=data_format
268273
)
269274
# We check the authentication
270275
if not metrics.is_auth_valid():
@@ -274,6 +279,7 @@ def device_data():
274279
# We store the metrics in our database
275280
for metric_data in simple_data.get('data'):
276281
store_metric(name=metric_data['name'], value=metric_data['value'])
282+
# Here the handler automatically computed the timestamp for each step
277283
for time_step in simple_data.get('historical_data'):
278284
for historical_metric_data in time_step:
279285
store_metric(name=metric_data['name'], value=metric_data['value'], time=time_step['timestamp'])
@@ -285,16 +291,19 @@ def device_data():
285291
# We can add extra data
286292
metrics.add_settings_to_answer({'language': 'fr-FR'})
287293
# The handler handles the signature, etc.
288-
return metrics.get_answer(), 200
294+
return metrics.get_answer_payload(), 200
289295
```
290296

291297

292298
## Changelog
293299

294300
### 2023-10-09 - v0.3.0
295301
- Fix token generation issue
302+
- Add support for OpenPAYGO Metrics Request Generation
303+
- Add support for OpenPAYGO Metrics Request Decoding
304+
- Add support for OpenPAYGO Metrics Response Generation
296305

297306
### 2023-10-03 - v0.2.0
298307
- First working version published on PyPI
299308
- Has support for OpenPAYGO Token
300-
- Has working CI for pushing to PyPI
309+
- Has working CI for pushing to PyPI

openpaygo/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ def generate_token(**kwargs):
1111

1212

1313
def decode_token(**kwargs):
14-
return OpenPAYGOTokenDecoder.decode_token(**kwargs)
14+
return OpenPAYGOTokenDecoder.decode_token(**kwargs)

openpaygo/metrics_request.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,19 @@ def get_condensed_request_payload(self):
5454

5555
def get_condensed_request_dict(self):
5656
if not self.data_format:
57-
raise ValueError('No data format provided for condensed request')
58-
condensed_request = self.request_dict
57+
raise ValueError('No Data Format provided for condensed request')
58+
data_order = self.data_format.get('data_order')
59+
if self.data and not data_order:
60+
raise ValueError('Data Format does not contain data_order')
61+
historical_data_order = self.data_format.get('historical_data_order')
62+
if self.historical_data and not historical_data_order:
63+
raise ValueError('Data Format does not contain historical_data_order')
64+
condensed_request = copy.deepcopy(self.request_dict)
5965
condensed_request['data'] = []
6066
condensed_request['historical_data'] = []
6167
# We add the data
6268
data_copy = copy.deepcopy(self.data)
63-
for var in self.data_format.get('data_order'):
69+
for var in data_order:
6470
condensed_request['data'].append(data_copy.pop(var) if var in data_copy else None)
6571
if len(data_copy) > 0:
6672
raise ValueError('Additional variables not present in the data format: '+str(data_copy))
@@ -69,7 +75,7 @@ def get_condensed_request_dict(self):
6975
historical_data_copy = copy.deepcopy(self.historical_data)
7076
for time_step in historical_data_copy:
7177
time_step_data = []
72-
for var in self.data_format.get('historical_data_order'):
78+
for var in historical_data_order:
7379
time_step_data.append(time_step.pop(var) if var in time_step else None)
7480
if len(time_step) > 0:
7581
raise ValueError('Additional variables not present in the historical data format: '+str(time_step))

openpaygo/metrics_response.py

Lines changed: 156 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,184 @@
11
import json
2-
2+
from .metrics_shared import OpenPAYGOMetricsShared
3+
import copy
4+
from datetime import datetime, timedelta
35

46
class MetricsResponseHandler(object):
57

6-
def __init__(self, received_metrics, data_format=None, secret_key=None):
8+
def __init__(self, received_metrics, data_format=None, secret_key=None, last_request_count=None, last_request_timestamp=None):
79
self.received_metrics = received_metrics
8-
self.metrics_dict = json.loads(received_metrics)
9-
self.answer_dict = {}
10+
self.request_dict = json.loads(received_metrics)
11+
# We convert the base variable names to simple
12+
self.request_dict = OpenPAYGOMetricsShared.convert_dict_keys_to_simple(self.request_dict)
13+
# We add the reception timestamp if not timestamp was provided
14+
if not self.request_dict.get('timestamp'):
15+
self.timestamp = int(datetime.now().timestamp())
16+
else:
17+
self.timestamp = self.request_dict.get('timestamp')
18+
self.response_dict = {}
19+
self.secret_key = secret_key
20+
self.data_format = data_format
21+
self.last_request_count = last_request_count
22+
self.last_request_timestamp = last_request_timestamp
23+
if not self.data_format and self.request_dict.get('data_format'):
24+
self.data_format = self.request_dict.get('data_format')
1025

1126
def get_device_serial(self):
12-
return self.received_metrics.get('sn', self.received_metrics.get('serial_number'))
27+
return self.request_dict.get('serial_number')
28+
29+
def get_data_format_id(self):
30+
return self.request_dict.get('data_format_id')
31+
32+
def data_format_available(self):
33+
return self.data_format != None
1334

1435
def set_device_parameters(self, secret_key, data_format):
15-
pass
36+
self.secret_key = secret_key
37+
self.data_format = data_format
1638

1739
def is_auth_valid(self):
18-
pass
40+
auth_string = self.request_dict.get('auth', None)
41+
if not auth_string:
42+
return True
43+
elif not self.secret_key:
44+
raise ValueError('Secret key is required to check the auth.')
45+
self.auth_method = auth_string[:2]
46+
new_signature = OpenPAYGOMetricsShared.generate_request_signature_from_data(self.request_dict, self.auth_method, self.secret_key)
47+
if auth_string == new_signature:
48+
request_count = self.request_dict.get('request_count')
49+
if request_count and self.last_request_count and request_count <= self.last_request_count:
50+
return False
51+
timestamp = self.request_dict.get('timestamp')
52+
if timestamp and self.last_request_timestamp and timestamp <= self.last_request_timestamp:
53+
return False
54+
return True
55+
return False
1956

2057
def get_simple_metrics(self):
21-
pass
58+
# We start the process by making a copy of the dict to work with
59+
simple_dict = copy.deepcopy(self.request_dict)
60+
simple_dict.pop('auth') if 'auth' in simple_dict else None # We remove the auth
61+
# We process the data and replace it
62+
simple_dict['data'] = self._get_simple_data()
63+
# We process the historical data
64+
simple_dict['historical_data'] = self._get_simple_historical_data()
65+
# We fill in the timestamps for each time step
66+
simple_dict['historical_data'] = self._fill_timestamp_in_historical_data(simple_dict['historical_data'])
67+
return simple_dict
2268

2369
def expects_token_answer(self):
24-
pass
70+
data = self._get_simple_data()
71+
return data.get('token_count') is not None
2572

2673
def add_tokens_to_answer(self, token_list):
27-
pass
74+
self.response_dict['token_list'] = token_list
2875

2976
def expects_time_answer(self):
30-
pass
77+
data = self._get_simple_data()
78+
if data.get('active_until_timestamp_requested', False) or data.get('active_seconds_left_requested', False):
79+
return True
80+
return False
3181

3282
def add_time_to_answer(self, target_datetime):
33-
pass
83+
data = self._get_simple_data()
84+
if data.get('active_until_timestamp_requested', False):
85+
self.response_dict['active_until_timestamp'] = target_datetime.timestamp()
86+
elif data.get('active_seconds_left_requested', False):
87+
self.response_dict['active_seconds_left'] = (datetime.now() - target_datetime).total_seconds()
88+
else:
89+
raise ValueError('No time requested')
90+
91+
def add_new_base_url_to_answer(self, new_base_url):
92+
self.add_settings_to_answer({'base_url': new_base_url})
3493

3594
def add_settings_to_answer(self, settings_dict):
36-
pass
95+
if not self.response_dict.get('settings'):
96+
self.response_dict['settings'] = {}
97+
self.response_dict['settings'].update(settings_dict)
3798

3899
def add_extra_data_to_answer(self, extra_data_dict):
39-
pass
100+
if not self.response_dict.get('extra_data'):
101+
self.response_dict['extra_data'] = {}
102+
self.response_dict['extra_data'].update(extra_data_dict)
40103

41-
def get_answer(self):
42-
pass
104+
def get_answer_payload(self):
105+
payload = self.get_answer_dict()
106+
return OpenPAYGOMetricsShared.convert_to_metrics_json(payload)
43107

44-
def _check_data_auth(self):
45-
data = self.metrics_dict.get('data', None)
46-
data_string = json.dumps(data, separators=(',', ':')) if data else ''
47-
historical_data = self.metrics_dict.get('historical_data', None)
48-
historical_data_string = json.dumps(historical_data, separators=(',', ':')) if historical_data else ''
108+
def get_answer_dict(self):
109+
# If there is not data format, we just return the full response
110+
condensed_answer = copy.deepcopy(self.response_dict)
111+
condensed_answer['auth'] = OpenPAYGOMetricsShared.generate_response_signature_from_data(
112+
serial_number=self.request_dict.get('serial_number'),
113+
request_count=self.request_dict.get('request_count'),
114+
data=condensed_answer,
115+
timestamp=self.request_dict.get('timestamp'),
116+
secret_key=self.secret_key
117+
)
118+
return OpenPAYGOMetricsShared.convert_dict_keys_to_condensed(condensed_answer)
49119

120+
def _get_simple_data(self):
121+
data = copy.deepcopy(self.request_dict.get('data'))
122+
# If no data or not condensed in list, we just return it
123+
if not data:
124+
return {}
125+
if not isinstance(data, list):
126+
return data
127+
data_order = self.data_format.get('data_order')
128+
if not data_order:
129+
raise ValueError('Data Format does not contain data_order')
130+
clean_data = {}
131+
data_len = len(data)
132+
for idx, var in enumerate(data_order):
133+
clean_data[var] = data[idx] if idx < data_len else None
134+
data = data[data_len:]
135+
if len(data) > 0:
136+
raise ValueError('Additional variables not present in the data format: '+str(data))
137+
return OpenPAYGOMetricsShared.convert_dict_keys_to_simple(clean_data)
138+
139+
def _get_simple_historical_data(self):
140+
historical_data = copy.deepcopy(self.request_dict.get('historical_data'))
141+
if not historical_data:
142+
return []
143+
historical_data_order = self.data_format.get('historical_data_order')
144+
clean_historical_data = []
145+
for time_step in historical_data:
146+
time_step_data = {}
147+
if isinstance(time_step, list):
148+
if not historical_data_order:
149+
raise ValueError('Data Format does not contain historical_data_order')
150+
timse_step_len = len(time_step)
151+
for idx, var in enumerate(historical_data_order):
152+
if idx < timse_step_len:
153+
time_step_data[var] = time_step[idx]
154+
time_step = time_step[timse_step_len:]
155+
if len(time_step) > 0:
156+
raise ValueError('Additional variables not present in the historical data format: '+str(time_step))
157+
elif isinstance(time_step, dict):
158+
for key in time_step:
159+
if key.isdigit() and int(key) < len(historical_data_order):
160+
time_step_data[historical_data_order[int(key)]] = time_step[key]
161+
else:
162+
time_step_data[key] = time_step[key]
163+
else:
164+
raise ValueError('Invalid historical data step type: '+str(time_step))
165+
clean_historical_data.append(time_step_data)
166+
return clean_historical_data
167+
168+
def _fill_timestamp_in_historical_data(self, historical_data):
169+
if self.request_dict.get('data_collection_timestamp'):
170+
last_timestamp = datetime.fromtimestamp(self.request_dict.get('data_collection_timestamp'))
171+
else:
172+
last_timestamp = datetime.fromtimestamp(self.timestamp)
173+
for idx, time_step in enumerate(historical_data):
174+
if time_step.get('relative_time') is not None:
175+
last_timestamp = last_timestamp + timedelta(seconds=int(time_step.get('relative_time')))
176+
historical_data[idx]['timestamp'] = int(last_timestamp.timestamp())
177+
del historical_data[idx]['relative_time']
178+
elif time_step.get('timestamp'):
179+
last_timestamp = datetime.fromtimestamp(time_step.get('timestamp'))
180+
else:
181+
if idx != 0:
182+
last_timestamp = last_timestamp + timedelta(seconds=int(self.data_format.get('historical_data_interval')))
183+
historical_data[idx]['timestamp'] = int(last_timestamp.timestamp())
184+
return historical_data

0 commit comments

Comments
 (0)