utils.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. # SPDX-License-Identifier: Apache-2.0
  2. # Copyright 2020 Contributors to OpenLEADR
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. from datetime import datetime, timedelta, timezone
  13. from dataclasses import is_dataclass, asdict
  14. from collections import OrderedDict
  15. import itertools
  16. import re
  17. import ssl
  18. import hashlib
  19. import uuid
  20. import logging
  21. logger = logging.getLogger('openleadr')
  22. DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
  23. DATETIME_FORMAT_NO_MICROSECONDS = "%Y-%m-%dT%H:%M:%SZ"
  24. def generate_id(*args, **kwargs):
  25. """
  26. Generate a string that can be used as an identifier in OpenADR messages.
  27. """
  28. return str(uuid.uuid4())
  29. def indent_xml(message):
  30. """
  31. Indents the XML in a nice way.
  32. """
  33. INDENT_SIZE = 2
  34. lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
  35. indent = 0
  36. for i, line in enumerate(lines):
  37. if i == 0:
  38. continue
  39. if re.search(r'^</[^>]+>$', line):
  40. indent = indent - INDENT_SIZE
  41. lines[i] = " " * indent + line
  42. if not (re.search(r'</[^>]+>$', line) or line.endswith("/>")):
  43. indent = indent + INDENT_SIZE
  44. return "\n".join(lines)
  45. def flatten_xml(message):
  46. """
  47. Flatten the entire XML structure.
  48. """
  49. lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
  50. for line in lines:
  51. line = re.sub(r'\n', '', line)
  52. line = re.sub(r'\s\s+', ' ', line)
  53. return "".join(lines)
  54. def normalize_dict(ordered_dict):
  55. """
  56. Main conversion function for the output of xmltodict to the OpenLEADR
  57. representation of OpenADR contents.
  58. :param ordered_dict dict: The OrderedDict, dict or dataclass that you wish to convert.
  59. """
  60. if is_dataclass(ordered_dict):
  61. ordered_dict = asdict(ordered_dict)
  62. def normalize_key(key):
  63. if key.startswith('oadr'):
  64. key = key[4:]
  65. elif key.startswith('ei'):
  66. key = key[2:]
  67. key = re.sub(r'([a-z])([A-Z])', r'\1_\2', key)
  68. if '-' in key:
  69. key = key.replace('-', '_')
  70. return key.lower()
  71. d = {}
  72. for key, value in ordered_dict.items():
  73. # Interpret values from the dict
  74. if key.startswith("@"):
  75. continue
  76. key = normalize_key(key)
  77. if isinstance(value, (OrderedDict, dict)):
  78. d[key] = normalize_dict(value)
  79. elif isinstance(value, list):
  80. d[key] = []
  81. for item in value:
  82. if isinstance(item, (OrderedDict, dict)):
  83. dict_item = normalize_dict(item)
  84. d[key].append(normalize_dict(dict_item))
  85. else:
  86. d[key].append(item)
  87. elif key in ("duration", "startafter", "max_period", "min_period"):
  88. d[key] = parse_duration(value)
  89. elif ("date_time" in key or key == "dtstart") and isinstance(value, str):
  90. d[key] = parse_datetime(value)
  91. elif value in ('true', 'false'):
  92. d[key] = parse_boolean(value)
  93. elif isinstance(value, str):
  94. if re.match(r'^-?\d+$', value):
  95. d[key] = int(value)
  96. elif re.match(r'^-?[\d.]+$', value):
  97. d[key] = float(value)
  98. else:
  99. d[key] = value
  100. else:
  101. d[key] = value
  102. # Do our best to make the dictionary structure as pythonic as possible
  103. if key.startswith("x_ei_"):
  104. d[key[5:]] = d.pop(key)
  105. key = key[5:]
  106. # Group all targets as a list of dicts under the key "target"
  107. if key == 'target':
  108. targets = d.pop(key)
  109. new_targets = []
  110. if targets:
  111. for ikey in targets:
  112. if isinstance(targets[ikey], list):
  113. new_targets.extend([{ikey: value} for value in targets[ikey]])
  114. else:
  115. new_targets.append({ikey: targets[ikey]})
  116. d[key + "s"] = new_targets
  117. key = key + "s"
  118. # Also add a targets_by_type element to this dict
  119. # to access the targets in a more convenient way.
  120. d['targets_by_type'] = group_targets_by_type(new_targets)
  121. # Group all reports as a list of dicts under the key "pending_reports"
  122. if key == "pending_reports":
  123. if isinstance(d[key], dict) and 'report_request_id' in d[key] \
  124. and isinstance(d[key]['report_request_id'], list):
  125. d['pending_reports'] = [{'request_id': rrid}
  126. for rrid in d['pending_reports']['report_request_id']]
  127. # Group all events al a list of dicts under the key "events"
  128. elif key == "event" and isinstance(d[key], list):
  129. events = d.pop("event")
  130. new_events = []
  131. for event in events:
  132. new_event = event['event']
  133. new_event['response_required'] = event['response_required']
  134. new_events.append(new_event)
  135. d["events"] = new_events
  136. # If there's only one event, also put it into a list
  137. elif key == "event" and isinstance(d[key], dict) and "event" in d[key]:
  138. oadr_event = d.pop('event')
  139. ei_event = oadr_event['event']
  140. ei_event['response_required'] = oadr_event['response_required']
  141. d['events'] = [ei_event]
  142. elif key in ("request_event", "created_event") and isinstance(d[key], dict):
  143. d = d[key]
  144. # Plurarize some lists
  145. elif key in ('report_request', 'report', 'specifier_payload'):
  146. if isinstance(d[key], list):
  147. d[key + 's'] = d.pop(key)
  148. else:
  149. d[key + 's'] = [d.pop(key)]
  150. elif key in ('report_description', 'event_signal'):
  151. descriptions = d.pop(key)
  152. if not isinstance(descriptions, list):
  153. descriptions = [descriptions]
  154. for description in descriptions:
  155. # We want to make the identification of the measurement universal
  156. if 'voltage' in description:
  157. name, item = 'voltage', description.pop('voltage')
  158. elif 'power_real' in description:
  159. name, item = 'powerReal', description.pop('power_real')
  160. elif 'power_apparent' in description:
  161. name, item = 'powerApparent', description.pop('power_apparent')
  162. elif 'power_reactive' in description:
  163. name, item = 'powerReactive', description.pop('power_reactive')
  164. elif 'energy_real' in description:
  165. name, item = 'energyReal', description.pop('energy_real')
  166. elif 'energy_apparent' in description:
  167. name, item = 'energyApparent', description.pop('energy_apparent')
  168. elif 'energy_reactive' in description:
  169. name, item = 'energyReactive', description.pop('energy_reactive')
  170. elif 'frequency' in description:
  171. name, item = 'frequency', description.pop('frequency')
  172. elif 'pulse_count' in description:
  173. name, item = 'pulseCount', description.pop('pulse_count')
  174. elif 'temperature' in description:
  175. name, item = 'temperature', description.pop('temperature')
  176. elif 'therm' in description:
  177. name, item = 'therm', description.pop('therm')
  178. elif 'currency' in description:
  179. name, item = 'currency', description.pop('currency')
  180. elif 'currency_per_kw' in description:
  181. name, item = 'currencyPerKW', description.pop('currency_per_kw')
  182. elif 'currency_per_kwh' in description:
  183. name, item = 'currencyPerKWh', description.pop('currency_per_kwh')
  184. elif 'currency_per_therm' in description:
  185. name, item = 'currencyPerTherm', description.pop('currency_per_therm')
  186. elif 'custom_unit' in description:
  187. name, item = 'customUnit', description.pop('custom_unit')
  188. else:
  189. break
  190. item['description'] = item.pop('item_description', None)
  191. item['unit'] = item.pop('item_units', None)
  192. item['scale'] = item.pop('si_scale_code', None)
  193. description['measurement'] = {'name': name,
  194. **item}
  195. d[key + 's'] = descriptions
  196. # Promote the contents of the Qualified Event ID
  197. elif key == "qualified_event_id" and isinstance(d['qualified_event_id'], dict):
  198. qeid = d.pop('qualified_event_id')
  199. d['event_id'] = qeid['event_id']
  200. d['modification_number'] = qeid['modification_number']
  201. # Durations are encapsulated in their own object, remove this nesting
  202. elif isinstance(d[key], dict) and "duration" in d[key] and len(d[key]) == 1:
  203. d[key] = d[key]["duration"]
  204. # In general, remove all double nesting
  205. elif isinstance(d[key], dict) and key in d[key] and len(d[key]) == 1:
  206. d[key] = d[key][key]
  207. # In general, remove the double nesting of lists of items
  208. elif isinstance(d[key], dict) and key[:-1] in d[key] and len(d[key]) == 1:
  209. if isinstance(d[key][key[:-1]], list):
  210. d[key] = d[key][key[:-1]]
  211. else:
  212. d[key] = [d[key][key[:-1]]]
  213. # Payload values are wrapped in an object according to their type. We don't need that.
  214. elif key in ("signal_payload", "current_value"):
  215. value = d[key]
  216. if isinstance(d[key], dict):
  217. if 'payload_float' in d[key] and 'value' in d[key]['payload_float'] \
  218. and d[key]['payload_float']['value'] is not None:
  219. d[key] = float(d[key]['payload_float']['value'])
  220. elif 'payload_int' in d[key] and 'value' in d[key]['payload_int'] \
  221. and d[key]['payload_int'] is not None:
  222. d[key] = int(d[key]['payload_int']['value'])
  223. # Report payloads contain an r_id and a type-wrapped payload_float
  224. elif key == 'report_payload':
  225. if 'payload_float' in d[key] and 'value' in d[key]['payload_float']:
  226. v = d[key].pop('payload_float')
  227. d[key]['value'] = float(v['value'])
  228. elif 'payload_int' in d[key] and 'value' in d[key]['payload_int']:
  229. v = d[key].pop('payload_float')
  230. d[key]['value'] = int(v['value'])
  231. # All values other than 'false' must be interpreted as True for testEvent (rule 006)
  232. elif key == 'test_event' and not isinstance(d[key], bool):
  233. d[key] = True
  234. # Promote the 'text' item
  235. elif isinstance(d[key], dict) and "text" in d[key] and len(d[key]) == 1:
  236. if key == 'uid':
  237. d[key] = int(d[key]["text"])
  238. else:
  239. d[key] = d[key]["text"]
  240. # Promote a 'date-time' item
  241. elif isinstance(d[key], dict) and "date_time" in d[key] and len(d[key]) == 1:
  242. d[key] = d[key]["date_time"]
  243. # Promote 'properties' item, discard the unused? 'components' item
  244. elif isinstance(d[key], dict) and "properties" in d[key] and len(d[key]) <= 2:
  245. d[key] = d[key]["properties"]
  246. # Remove all empty dicts
  247. elif isinstance(d[key], dict) and len(d[key]) == 0:
  248. d.pop(key)
  249. return d
  250. def parse_datetime(value):
  251. """
  252. Parse an ISO8601 datetime into a datetime.datetime object.
  253. """
  254. matches = re.match(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.?(\d{1,6})?\d*Z', value)
  255. if matches:
  256. year, month, day, hour, minute, second, micro = (int(value) for value in matches.groups())
  257. return datetime(year, month, day, hour, minute, second, micro, tzinfo=timezone.utc)
  258. else:
  259. print(f"{value} did not match format")
  260. return value
  261. def parse_duration(value):
  262. """
  263. Parse a RFC5545 duration.
  264. """
  265. # TODO: implement the full regex:
  266. # matches = re.match(r'(\+|\-)?P((\d+Y)?(\d+M)?(\d+D)?T?(\d+H)?(\d+M)?(\d+S)?)|(\d+W)', value)
  267. if isinstance(value, timedelta):
  268. return value
  269. matches = re.match(r'P(\d+(?:D|W))?T?(\d+H)?(\d+M)?(\d+S)?', value)
  270. if not matches:
  271. return False
  272. days = hours = minutes = seconds = 0
  273. _days, _hours, _minutes, _seconds = matches.groups()
  274. if _days:
  275. if _days.endswith("D"):
  276. days = int(_days[:-1])
  277. elif _days.endswith("W"):
  278. days = int(_days[:-1]) * 7
  279. if _hours:
  280. hours = int(_hours[:-1])
  281. if _minutes:
  282. minutes = int(_minutes[:-1])
  283. if _seconds:
  284. seconds = int(_seconds[:-1])
  285. return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
  286. def parse_boolean(value):
  287. if value == 'true':
  288. return True
  289. else:
  290. return False
  291. def peek(iterable):
  292. """
  293. Peek into an iterable.
  294. """
  295. try:
  296. first = next(iterable)
  297. except StopIteration:
  298. return None
  299. else:
  300. return itertools.chain([first], iterable)
  301. def datetimeformat(value, format=DATETIME_FORMAT):
  302. """
  303. Format a given datetime as a UTC ISO3339 string.
  304. """
  305. if not isinstance(value, datetime):
  306. return value
  307. return value.astimezone(timezone.utc).strftime(format)
  308. def timedeltaformat(value):
  309. """
  310. Format a timedelta to a RFC5545 Duration.
  311. """
  312. if not isinstance(value, timedelta):
  313. return value
  314. days = value.days
  315. hours, seconds = divmod(value.seconds, 3600)
  316. minutes, seconds = divmod(seconds, 60)
  317. formatted = "P"
  318. if days:
  319. formatted += f"{days}D"
  320. if hours or minutes or seconds:
  321. formatted += "T"
  322. if hours:
  323. formatted += f"{hours}H"
  324. if minutes:
  325. formatted += f"{minutes}M"
  326. if seconds:
  327. formatted += f"{seconds}S"
  328. return formatted
  329. def booleanformat(value):
  330. """
  331. Format a boolean value
  332. """
  333. if isinstance(value, bool):
  334. if value is True:
  335. return "true"
  336. elif value is False:
  337. return "false"
  338. elif value in ("true", "false"):
  339. return value
  340. else:
  341. raise ValueError(f"A boolean value must be provided, not {value}.")
  342. def ensure_bytes(obj):
  343. """
  344. Converts a utf-8 str object to bytes.
  345. """
  346. if obj is None:
  347. return obj
  348. if isinstance(obj, bytes):
  349. return obj
  350. if isinstance(obj, str):
  351. return bytes(obj, 'utf-8')
  352. else:
  353. raise TypeError("Must be bytes or str")
  354. def ensure_str(obj):
  355. """
  356. Converts bytes to a utf-8 string.
  357. """
  358. if obj is None:
  359. return None
  360. if isinstance(obj, str):
  361. return obj
  362. if isinstance(obj, bytes):
  363. return obj.decode('utf-8')
  364. else:
  365. raise TypeError("Must be bytes or str")
  366. def certificate_fingerprint_from_der(der_bytes):
  367. hash = hashlib.sha256(der_bytes).digest().hex()
  368. return ":".join([hash[i-2:i].upper() for i in range(-20, 0, 2)])
  369. def certificate_fingerprint(certificate_str):
  370. """
  371. Calculate the fingerprint for the given certificate, as defined by OpenADR.
  372. """
  373. der_bytes = ssl.PEM_cert_to_DER_cert(ensure_str(certificate_str))
  374. return certificate_fingerprint_from_der(der_bytes)
  375. def extract_pem_cert(tree):
  376. """
  377. Extract a given X509 certificate inside an XML tree and return the standard
  378. form of a PEM-encoded certificate.
  379. :param tree lxml.etree: The tree that contains the X509 element. This is
  380. usually the KeyInfo element from the XMLDsig Signature
  381. part of the message.
  382. """
  383. cert = tree.find('.//{http://www.w3.org/2000/09/xmldsig#}X509Certificate').text
  384. return "-----BEGIN CERTIFICATE-----\n" + cert + "-----END CERTIFICATE-----\n"
  385. def find_by(dict_or_list, key, value, *args):
  386. """
  387. Find a dict inside a dict or list by key, value properties.
  388. """
  389. search_params = [(key, value)]
  390. if args:
  391. search_params += [(args[i], args[i+1]) for i in range(0, len(args), 2)]
  392. if isinstance(dict_or_list, dict):
  393. dict_or_list = dict_or_list.values()
  394. for item in dict_or_list:
  395. if not isinstance(item, dict):
  396. _item = item.__dict__
  397. else:
  398. _item = item
  399. for key, value in search_params:
  400. if isinstance(value, tuple):
  401. if _item[key] not in value:
  402. break
  403. else:
  404. if _item[key] != value:
  405. break
  406. else:
  407. return item
  408. else:
  409. return None
  410. def group_by(list_, key, pop_key=False):
  411. """
  412. Return a dict that groups values
  413. """
  414. grouped = {}
  415. key_path = key.split(".")
  416. for item in list_:
  417. value = item
  418. for key in key_path:
  419. value = value.get(key)
  420. if value not in grouped:
  421. grouped[value] = []
  422. grouped[value].append(item)
  423. return grouped
  424. def cron_config(interval):
  425. """
  426. Returns a dict with cron settings for the given interval
  427. """
  428. if interval < timedelta(minutes=1):
  429. second = f"*/{interval.seconds}"
  430. minute = "*"
  431. hour = "*"
  432. elif interval < timedelta(hours=1):
  433. second = "0"
  434. minute = f"*/{int(interval.total_seconds/60)}"
  435. hour = "*"
  436. elif interval < timedelta(days=1):
  437. second = "0"
  438. minute = "0"
  439. hour = f"*/{int(interval.total_seconds/3600)}"
  440. return {"second": second, "minute": minute, "hour": hour}
  441. def get_cert_fingerprint_from_request(request):
  442. ssl_object = request.transport.get_extra_info('ssl_object')
  443. if ssl_object:
  444. der_bytes = ssl_object.getpeercert(binary_form=True)
  445. if der_bytes:
  446. return certificate_fingerprint_from_der(der_bytes)
  447. def get_certificate_common_name(request):
  448. cert = request.transport.get_extra_info('peercert')
  449. if cert:
  450. subject = dict(x[0] for x in cert['subject'])
  451. return subject.get('commonName')
  452. def group_targets_by_type(list_of_targets):
  453. targets_by_type = {}
  454. for target in list_of_targets:
  455. for key, value in target.items():
  456. if value is None:
  457. continue
  458. if key not in targets_by_type:
  459. targets_by_type[key] = []
  460. targets_by_type[key].append(value)
  461. return targets_by_type
  462. def ungroup_targets_by_type(targets_by_type):
  463. ungrouped_targets = []
  464. for target_type, targets in targets_by_type.items():
  465. if isinstance(targets, list):
  466. for target in targets:
  467. ungrouped_targets.append({target_type: target})
  468. elif isinstance(targets, str):
  469. ungrouped_targets.append({target_type: targets})
  470. return ungrouped_targets
  471. def validate_report_measurement_dict(measurement):
  472. from openleadr.enums import _ACCEPTABLE_UNITS, _MEASUREMENT_DESCRIPTIONS
  473. if 'name' not in measurement \
  474. or 'description' not in measurement \
  475. or 'unit' not in measurement:
  476. raise ValueError("The measurement dict must contain the following keys: "
  477. "'name', 'description', 'unit'. Please correct this.")
  478. name = measurement['name']
  479. description = measurement['description']
  480. unit = measurement['unit']
  481. # Validate the item name and description match
  482. if name in _MEASUREMENT_DESCRIPTIONS:
  483. required_description = _MEASUREMENT_DESCRIPTIONS[name]
  484. if description != required_description:
  485. if description.lower() == required_description.lower():
  486. logger.warning(f"The description for the measurement with name {name} "
  487. f"was not in the correct case; you provided {description} but "
  488. f"it should be {required_description}. "
  489. "This was automatically corrected.")
  490. measurement['description'] = required_description
  491. else:
  492. raise ValueError(f"The measurement's description {description} "
  493. f"did not match the expected description for this type "
  494. f" ({required_description}). Please correct this, or use "
  495. "'customUnit' as the name.")
  496. if unit not in _ACCEPTABLE_UNITS[name]:
  497. raise ValueError(f"The unit {unit} is not acceptable for measurement {name}. Allowed "
  498. f"units are {_ACCEPTABLE_UNITS[name]}.")
  499. else:
  500. if name != 'customUnit':
  501. logger.warning(f"You provided a measurement with an unknown name {name}. "
  502. "This was corrected to 'customUnit'. Please correct this in your "
  503. "report definition.")
  504. measurement['report_description']['name'] = 'customUnit'
  505. if 'power' in name:
  506. if 'power_attributes' in measurement:
  507. power_attributes = measurement['power_attributes']
  508. if 'voltage' not in power_attributes \
  509. or 'ac' not in power_attributes \
  510. or 'hertz' not in power_attributes:
  511. raise ValueError("The power_attributes of the measurement must contain the "
  512. "following keys: 'voltage' (int), 'ac' (bool), 'hertz' (int).")
  513. else:
  514. raise ValueError("A 'power' related measurement must contain a "
  515. "'power_attributes' section that contains the following "
  516. "keys: 'voltage' (int), 'ac' (boolean), 'hertz' (int)")