Skip to content

Commit a246f0e

Browse files
authored
Merge pull request #1 from EnAccess/add-openpaygo-metrics
Add OpenPAYGO Metrics support
2 parents 8f2e886 + 414e23a commit a246f0e

10 files changed

Lines changed: 586 additions & 17 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
*.pyc
22
dist
3-
openpaygo.egg-info
3+
openpaygo.egg-info
4+
.DS_store

README.md

Lines changed: 160 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,30 @@ This open-source project was sponsored by:
2121

2222
## Table of Contents
2323

24+
- [Key Features](#key-features)
2425
- [Installing the library](#installing-the-library)
25-
- [Getting Started - Generating Tokens](#getting-started---generating-tokens)
26-
- [Getting Started - Decoding a Token](#getting-started---decoding-a-token)
26+
- [Getting Started - OpenPAYGO Token](#getting-started---openpaygo-token)
27+
- [Generating Tokens](#generating-tokens)
28+
- [Decoding Tokens](#decoding-tokens)
29+
- [Getting Started - OpenPAYGO Metrics](#getting-started---openpaygo-metrics)
30+
- [Changelog](#changelog)
31+
- [2023-10-03 - v0.2.0](#2023-10-03---v020)
2732

33+
## Key Features
34+
- Implements token generation and decoding with full support for the v2.3 of the [OpenPAYGO Token](https://github.com/EnAccess/OpenPAYGO-Token) specifications.
35+
- Implements payload authentication (verification + signing) and conversion from simple to condensed payload (and back) with full support of the v1.0-rc1 of the [OpenPAYGO Metrics](https://github.com/openpaygo/metrics) specifications.
2836

2937
## Installing the library
3038

3139
You can install the library by running `pip install openpaygo` or adding `openpaygo` in your requirements.txt file and running `pip install -r requirements.txt`.
3240

3341
## Getting Started - OpenPAYGO Token
3442

35-
### Generating Tokens
43+
### Generating Tokens (Server Side)
3644

3745
You can use the `generate_token()` function to generate an OpenPAYGOToken Token. The function takes the following parameters, and they should match the configuration in the hardware of the device:
3846

39-
- `secret_key` (required): The secret key of the device. Must be passed as an hexadecimal string (with 32 characters).
47+
- `secret_key` (required): The secret key of the device. Must be passed as an hexadecimal string with 32 characters (e.g. `dac86b1a29ab82edc5fbbc41ec9530f6`).
4048
- `count` (required): The token count used to make the last token.
4149
- `value` (optional): The value to be passed in the token (typically the number of days of activation). Optional if the `token_type` is Disable PAYG or Counter Sync. The value must be between 0 and 995.
4250
- `token_type` (optional): Used to set the type of token (default is Add Time). Token types can be found in the `TokenType` class: ADD_TIME, SET_TIME, DISABLE_PAYG, COUNTER_SYNC.
@@ -87,12 +95,12 @@ device.save() # We save the new count that we set for the device
8795
```
8896

8997

90-
### Decoding Tokens
98+
### Decoding Tokens (Device Side)
9199

92100
You can use the `decode_token()` function to generate an OpenPAYGOToken Token. The function takes the following parameters, and they should match the configuration in the hardware of the device:
93101

94-
- `token` (required): The token that was given by the user
95-
- `secret_key` (required): The secret key of the device
102+
- `token` (required): The token that was given by the user, as a string
103+
- `secret_key` (required): The secret key of the device as a string with 32 hexadecimal characters (e.g. `dac86b1a29ab82edc5fbbc41ec9530f6`)
96104
- `count` (required): The token count of the last valid token. When a device is new, this is 1.
97105
- `used_counts` (optional): An array of recently used token counts, as returned by the function itself after the last valid token was decoded. This allows for handling unordered token entry.
98106
- `starting_code` (optional): If not provided, it is generated according to the method defined in the standard (SipHash-2-4 of the key, transformed to digit by the same method as the token generation).
@@ -149,9 +157,153 @@ elif token_type == TokenType.INVALID:
149157
```
150158

151159

160+
## Getting Started - OpenPAYGO Metrics
161+
162+
### Generating a Request (Device Side)
163+
164+
You can use the `MetricsRequestHandler` object to create a new OpenPAYGO Metrics request from start to finish. It accepts the following initial inputs:
165+
- `serial_number` (required): The serial number of the device
166+
- `data_format` (optional): The data format, provided as dictionnary matching the data format object specifications.
167+
- `secret_key` (optional): The secret key provided as a string containing 32 hexadecimal characters (e.g. `dac86b1a29ab82edc5fbbc41ec9530f6`). Required if `auth_method` is defined.
168+
- `auth_method` (optional): One of the auth method contained in the `AuthMethod` class.
169+
170+
It provides the following methods:
171+
- `set_timestamp(timestamp)`: Used to set the `timestamp` of the request.
172+
- `set_request_count(request_count)`: Used to set the `request_count` of the request.
173+
- `set_data(data)`: Used to set the `data` of the request, should be set in simple format as a dictionnay.
174+
- `set_historical_data(data)`: Used to set the `historical_data` of the request, should be set in simple format as a dictionnary. The data is assumed to be separated by the `historical_data_interval` unless an explicit timestamp is provided.
175+
- `get_simple_request_payload()`: Returns the payload in simple format as a string containing JSON and including the authentication signature.
176+
- `get_condensed_request_payload()`: Returns the payload in condensed format as a string containing JSON and including the authentication signature. It requires `data_format` to be set. The data is automatically condensed from the set data and the data format and the signature is automatically generated.
177+
178+
179+
**Example - Full Request flow from device side:**
180+
181+
```python
182+
from openpaygo import MetricsRequestHandler, AuthMethod
183+
import requests
184+
185+
# We assume the users enters a token and that the device state is saved in my_device_state
186+
...
187+
188+
metrics_request = MetricsRequestHandler(
189+
serial_number=my_device_state.serial_number,
190+
secret_key=my_device_state.secret_key,
191+
data_format=my_device_state.data_format,
192+
auth_method=AuthMethod.RECURSIVE_DATA_AUTH
193+
)
194+
195+
metrics_request.set_timestamp(1611583070)
196+
metrics_request.set_data({
197+
"token_count": 3,
198+
"tampered": False,
199+
"firmware_version": "1.2.3"
200+
})
201+
# Here we assume that the data we send is separated by 60 seconds as per the data format
202+
metrics_request.set_historical_data([
203+
{
204+
"panel_voltage": 12.31,
205+
"battery_voltage": 12.32,
206+
"panel_current": 1.23,
207+
"battery_current": -1.23,
208+
},
209+
{
210+
"panel_voltage": 12.30,
211+
"battery_voltage": 12.31,
212+
"panel_current": 1.22,
213+
"battery_current": -1.21,
214+
}
215+
])
216+
payload = metrics_request.get_condensed_request_payload()
217+
218+
# We can now proceed to send the payload to the URL
219+
# 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"}`
220+
response = requests.post('https://<base_url>/dd', data=payload, headers={'Content-Type':'application/json'})
221+
try:
222+
response.json().get('tkl', [])
223+
for tokens in tkl:
224+
# Here we decode the tokens received from the server and apply them (see example above)
225+
...
226+
```
227+
228+
229+
### Handling a Request and Generating a Response (Server Side)
230+
231+
You can use the `MetricsResponseHandler` object to process your OpenPAYGO Metrics request from start to finish. It accepts the following initial inputs:
232+
- `metrics_payload` (required): The OpenPAYGO Metrics payload, as a string containing the JSON payload.
233+
- `secret_key` (optional): The secret key provided as a string containing 32 hexadecimal characters (e.g. `dac86b1a29ab82edc5fbbc41ec9530f6`)
234+
- `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)
237+
238+
It provides the following methods:
239+
- `get_device_serial()`: Returns the serial number of the device as a string.
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.
242+
- `get_simple_metrics()`: Returns the metrics provided in the simple expanded format. It will also convert relative timestamps into explicit timestamps for easier processing.
243+
- `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.
244+
- `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.
245+
- `add_settings_to_answer(settings_dict)`: Will add the provided settings dictionnary to the answer.
246+
- `add_extra_data_to_answer(extra_data_dict)`: Will add the provided extra data dictionnary to the answer.
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.
249+
250+
251+
**Example - Full Request flow from server side:**
252+
253+
```python
254+
from openpaygo import MetricsResponseHandler
255+
from my_db_service import get_device, get_data_format, store_metric
256+
257+
258+
@app.route('/dd')
259+
def device_data():
260+
# We load the metrics
261+
try:
262+
metrics = MetricsResponseHandler(request.data)
263+
except ValueError as e:
264+
return {'error': 'Invalid data format'}, 400
265+
# We get the serial number and load the device data from our storage
266+
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
269+
# We set the device parameters in the metrics handler
270+
metrics.set_device_parameters(
271+
secret_key=device.secret_key,
272+
data_format=data_format
273+
)
274+
# We check the authentication
275+
if not metrics.is_auth_valid():
276+
return {'error': 'Invalid authentication'}, 403
277+
# We transform the condensed data received from the device in simple data
278+
simple_data = metrics.get_simple_metrics()
279+
# We store the metrics in our database
280+
for metric_data in simple_data.get('data'):
281+
store_metric(name=metric_data['name'], value=metric_data['value'])
282+
# Here the handler automatically computed the timestamp for each step
283+
for time_step in simple_data.get('historical_data'):
284+
for historical_metric_data in time_step:
285+
store_metric(name=metric_data['name'], value=metric_data['value'], time=time_step['timestamp'])
286+
# We prepare the answer
287+
if metrics.expects_token_answer():
288+
metrics.add_tokens_to_answer(device.pending_token_list)
289+
elif metrics.expects_time_answer():
290+
metrics.add_time_to_answer(device.expiration_datetime)
291+
# We can add extra data
292+
metrics.add_settings_to_answer({'language': 'fr-FR'})
293+
# The handler handles the signature, etc.
294+
return metrics.get_answer_payload(), 200
295+
```
296+
297+
152298
## Changelog
153299

300+
### 2023-10-09 - v0.3.0
301+
- 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
305+
154306
### 2023-10-03 - v0.2.0
155307
- First working version published on PyPI
156308
- Has support for OpenPAYGO Token
157-
- Has working CI for pushing to PyPI
309+
- Has working CI for pushing to PyPI

openpaygo/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from .encode_token import OpenPAYGOTokenEncoder
2-
from .decode_token import OpenPAYGOTokenDecoder
3-
from .shared import TokenType
1+
from .token_encode import OpenPAYGOTokenEncoder
2+
from .token_decode import OpenPAYGOTokenDecoder
3+
from .token_shared import TokenType
4+
from .metrics_request import MetricsRequestHandler
5+
from .metrics_response import MetricsResponseHandler
6+
from .metrics_shared import AuthMethod
47

58

69
def generate_token(**kwargs):
710
return OpenPAYGOTokenEncoder.generate_token(**kwargs)
811

912

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

openpaygo/metrics_request.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from .metrics_shared import OpenPAYGOMetricsShared
2+
import copy
3+
4+
5+
class MetricsRequestHandler(object):
6+
7+
def __init__(self, serial_number, data_format=None, secret_key=None, auth_method=None):
8+
self.secret_key = secret_key
9+
self.auth_method = auth_method
10+
self.request_dict = {
11+
'serial_number': serial_number,
12+
}
13+
self.data_format = data_format
14+
if self.data_format:
15+
if self.data_format.get('id'):
16+
self.request_dict['data_format_id'] = data_format.get('id')
17+
else:
18+
self.request_dict['data_format'] = data_format
19+
self.data = {}
20+
self.historical_data = {}
21+
22+
def set_request_count(self, request_count):
23+
self.request_dict['request_count'] = request_count
24+
25+
def set_timestamp(self, timestamp):
26+
self.request_dict['timestamp'] = timestamp
27+
28+
def set_data(self, data):
29+
self.data = data
30+
31+
def set_historical_data(self, historical_data):
32+
if not self.data_format.get('historical_data_interval'):
33+
for time_step in historical_data:
34+
if not time_step.get('timestamp'):
35+
raise ValueError('Historical Data objects must have a time stamp if no historical_data_interval is defined.')
36+
self.historical_data = historical_data
37+
38+
def get_simple_request_payload(self):
39+
payload = self.get_simple_request_dict()
40+
return OpenPAYGOMetricsShared.convert_to_metrics_json(payload)
41+
42+
def get_simple_request_dict(self):
43+
simple_request = self.request_dict
44+
simple_request['data'] = self.data
45+
simple_request['historical_data'] = self.historical_data
46+
# We prepare the auth
47+
if self.auth_method:
48+
simple_request['auth'] = OpenPAYGOMetricsShared.generate_request_signature_from_data(simple_request, self.auth_method, self.secret_key)
49+
return simple_request
50+
51+
def get_condensed_request_payload(self):
52+
payload = self.get_condensed_request_dict()
53+
return OpenPAYGOMetricsShared.convert_to_metrics_json(payload)
54+
55+
def get_condensed_request_dict(self):
56+
if not self.data_format:
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)
65+
condensed_request['data'] = []
66+
condensed_request['historical_data'] = []
67+
# We add the data
68+
data_copy = copy.deepcopy(self.data)
69+
for var in data_order:
70+
condensed_request['data'].append(data_copy.pop(var) if var in data_copy else None)
71+
if len(data_copy) > 0:
72+
raise ValueError('Additional variables not present in the data format: '+str(data_copy))
73+
condensed_request['data'] = OpenPAYGOMetricsShared.remove_trailing_empty_elements(condensed_request['data'])
74+
# We add the historical data
75+
historical_data_copy = copy.deepcopy(self.historical_data)
76+
for time_step in historical_data_copy:
77+
time_step_data = []
78+
for var in historical_data_order:
79+
time_step_data.append(time_step.pop(var) if var in time_step else None)
80+
if len(time_step) > 0:
81+
raise ValueError('Additional variables not present in the historical data format: '+str(time_step))
82+
time_step_data = OpenPAYGOMetricsShared.remove_trailing_empty_elements(time_step_data)
83+
condensed_request['historical_data'].append(time_step_data)
84+
# We prepare the auth
85+
if self.auth_method:
86+
condensed_request['auth'] = OpenPAYGOMetricsShared.generate_request_signature_from_data(condensed_request, self.auth_method, self.secret_key)
87+
# We replace the key names by the condensed ones
88+
condensed_request = OpenPAYGOMetricsShared.convert_dict_keys_to_condensed(condensed_request)
89+
return condensed_request

0 commit comments

Comments
 (0)