utils.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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 asyncio import iscoroutine
  13. from datetime import datetime, timedelta, timezone
  14. from dataclasses import is_dataclass, asdict
  15. import random
  16. import string
  17. from collections import OrderedDict
  18. import itertools
  19. import re
  20. import ssl
  21. import hashlib
  22. import uuid
  23. from openleadr import config
  24. DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
  25. DATETIME_FORMAT_NO_MICROSECONDS = "%Y-%m-%dT%H:%M:%SZ"
  26. def generate_id(*args, **kwargs):
  27. """
  28. Generate a string that can be used as an identifier in OpenADR messages.
  29. """
  30. return str(uuid.uuid4())
  31. def indent_xml(message):
  32. """
  33. Indents the XML in a nice way.
  34. """
  35. INDENT_SIZE = 2
  36. lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
  37. indent = 0
  38. for i, line in enumerate(lines):
  39. if i == 0:
  40. continue
  41. if re.search(r'^</[^>]+>$', line):
  42. indent = indent - INDENT_SIZE
  43. lines[i] = " " * indent + line
  44. if not (re.search(r'</[^>]+>$', line) or line.endswith("/>")):
  45. indent = indent + INDENT_SIZE
  46. return "\n".join(lines)
  47. def flatten_xml(message):
  48. """
  49. Flatten the entire XML structure.
  50. """
  51. lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
  52. for line in lines:
  53. line = re.sub(r'\n', '', line)
  54. line = re.sub(r'\s\s+', ' ', line)
  55. return "".join(lines)
  56. def normalize_dict(ordered_dict):
  57. """
  58. Main conversion function for the output of xmltodict to the OpenLEADR
  59. representation of OpenADR contents.
  60. :param ordered_dict dict: The OrderedDict, dict or dataclass that you wish to convert.
  61. """
  62. if is_dataclass(ordered_dict):
  63. ordered_dict = asdict(ordered_dict)
  64. def normalize_key(key):
  65. if key.startswith('oadr'):
  66. key = key[4:]
  67. elif key.startswith('ei'):
  68. key = key[2:]
  69. key = re.sub(r'([a-z])([A-Z])', r'\1_\2', key)
  70. if '-' in key:
  71. key = key.replace('-', '_')
  72. return key.lower()
  73. d = {}
  74. for key, value in ordered_dict.items():
  75. # Interpret values from the dict
  76. if key.startswith("@"):
  77. continue
  78. key = normalize_key(key)
  79. if isinstance(value, (OrderedDict, dict)):
  80. d[key] = normalize_dict(value)
  81. elif isinstance(value, list):
  82. d[key] = []
  83. for item in value:
  84. if isinstance(item, (OrderedDict, dict)):
  85. dict_item = normalize_dict(item)
  86. d[key].append(normalize_dict(dict_item))
  87. else:
  88. d[key].append(item)
  89. elif key in ("duration", "startafter", "max_period", "min_period"):
  90. d[key] = parse_duration(value)
  91. elif ("date_time" in key or key == "dtstart") and isinstance(value, str):
  92. d[key] = parse_datetime(value)
  93. elif value in ('true', 'false'):
  94. d[key] = parse_boolean(value)
  95. elif isinstance(value, str):
  96. d[key] = parse_int(value) or parse_float(value) or value
  97. else:
  98. d[key] = value
  99. # Do our best to make the dictionary structure as pythonic as possible
  100. if key.startswith("x_ei_"):
  101. d[key[5:]] = d.pop(key)
  102. key = key[5:]
  103. # Group all targets as a list of dicts under the key "target"
  104. if key in ("target", "report_subject", "report_data_source"):
  105. targets = d.pop(key)
  106. new_targets = []
  107. if targets:
  108. for ikey in targets:
  109. if isinstance(targets[ikey], list):
  110. new_targets.extend([{ikey: value} for value in targets[ikey]])
  111. else:
  112. new_targets.append({ikey: targets[ikey]})
  113. d[key + "s"] = new_targets
  114. key = key + "s"
  115. # Dig up the properties inside some specific target identifiers
  116. # if key in ("aggregated_pnode", "pnode", "service_delivery_point"):
  117. # d[key] = d[key]["node"]
  118. # if key in ("end_device_asset", "meter_asset"):
  119. # d[key] = d[key]["mrid"]
  120. # Group all reports as a list of dicts under the key "pending_reports"
  121. if key == "pending_reports":
  122. if isinstance(d[key], dict) and 'report_request_id' in d[key] and isinstance(d[key]['report_request_id'], list):
  123. d['pending_reports'] = [{'request_id': rrid} for rrid in d['pending_reports']['report_request_id']]
  124. # Group all events al a list of dicts under the key "events"
  125. elif key == "event" and isinstance(d[key], list):
  126. events = d.pop("event")
  127. new_events = []
  128. for event in events:
  129. new_event = event['event']
  130. new_event['response_required'] = event['response_required']
  131. new_events.append(new_event)
  132. d["events"] = new_events
  133. # If there's only one event, also put it into a list
  134. elif key == "event" and isinstance(d[key], dict) and "event" in d[key]:
  135. oadr_event = d.pop('event')
  136. ei_event = oadr_event['event']
  137. ei_event['response_required'] = oadr_event['response_required']
  138. d['events'] = [ei_event]
  139. elif key in ("request_event", "created_event") and isinstance(d[key], dict):
  140. d = d[key]
  141. # Plurarize some lists
  142. elif key in ('report_request', 'report'):
  143. if isinstance(d[key], list):
  144. d[key + 's'] = d.pop(key)
  145. else:
  146. d[key + 's'] = [d.pop(key)]
  147. elif key == 'report_description':
  148. if isinstance(d[key], list):
  149. original_descriptions = d.pop(key)
  150. report_descriptions = {}
  151. for item in original_descriptions:
  152. r_id = item.pop('r_id')
  153. report_descriptions[r_id] = item
  154. d[key + 's'] = report_descriptions
  155. else:
  156. original_description = d.pop(key)
  157. r_id = original_description.pop('r_id')
  158. d[key + 's'] = {r_id: original_description}
  159. # Promote the contents of the Qualified Event ID
  160. elif key == "qualified_event_id" and isinstance(d['qualified_event_id'], dict):
  161. qeid = d.pop('qualified_event_id')
  162. d['event_id'] = qeid['event_id']
  163. d['modification_number'] = qeid['modification_number']
  164. # Promote the contents of the tolerance items
  165. # if key == "tolerance" and "tolerate" in d["tolerance"] and len(d["tolerance"]["tolerate"]) == 1:
  166. # d["tolerance"] = d["tolerance"]["tolerate"].values()[0]
  167. # Durations are encapsulated in their own object, remove this nesting
  168. elif isinstance(d[key], dict) and "duration" in d[key] and len(d[key]) == 1:
  169. d[key] = d[key]["duration"]
  170. # In general, remove all double nesting
  171. elif isinstance(d[key], dict) and key in d[key] and len(d[key]) == 1:
  172. d[key] = d[key][key]
  173. # In general, remove the double nesting of lists of items
  174. elif isinstance(d[key], dict) and key[:-1] in d[key] and len(d[key]) == 1:
  175. if isinstance(d[key][key[:-1]], list):
  176. d[key] = d[key][key[:-1]]
  177. else:
  178. d[key] = [d[key][key[:-1]]]
  179. # Payload values are wrapped in an object according to their type. We don't need that information.
  180. elif key in ("signal_payload", "current_value"):
  181. value = d[key]
  182. if isinstance(d[key], dict):
  183. if 'payload_float' in d[key] and 'value' in d[key]['payload_float'] and d[key]['payload_float']['value'] is not None:
  184. d[key] = float(d[key]['payload_float']['value'])
  185. elif 'payload_int' in d[key] and 'value' in d[key]['payload_int'] and d[key]['payload_int'] is not None:
  186. d[key] = int(d[key]['payload_int']['value'])
  187. # All values other than 'false' must be interpreted as True for testEvent (rule 006)
  188. elif key == 'test_event' and not isinstance(d[key], bool):
  189. d[key] = True
  190. # Promote the 'text' item
  191. elif isinstance(d[key], dict) and "text" in d[key] and len(d[key]) == 1:
  192. if key == 'uid':
  193. d[key] = int(d[key]["text"])
  194. else:
  195. d[key] = d[key]["text"]
  196. # Promote a 'date-time' item
  197. elif isinstance(d[key], dict) and "date_time" in d[key] and len(d[key]) == 1:
  198. d[key] = d[key]["date_time"]
  199. # Promote 'properties' item, discard the unused? 'components' item
  200. elif isinstance(d[key], dict) and "properties" in d[key] and len(d[key]) <= 2:
  201. d[key] = d[key]["properties"]
  202. # Remove all empty dicts
  203. elif isinstance(d[key], dict) and len(d[key]) == 0:
  204. d.pop(key)
  205. return d
  206. def parse_datetime(value):
  207. """
  208. Parse an ISO8601 datetime into a datetime.datetime object.
  209. """
  210. matches = re.match(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.?(\d{1,6})?\d*Z', value)
  211. if matches:
  212. year, month, day, hour, minute, second, microsecond = (int(value) for value in matches.groups())
  213. return datetime(year, month, day, hour, minute, second, microsecond=microsecond, tzinfo=timezone.utc)
  214. else:
  215. print(f"{value} did not match format")
  216. return value
  217. def parse_duration(value):
  218. """
  219. Parse a RFC5545 duration.
  220. """
  221. # TODO: implement the full regex: matches = re.match(r'(\+|\-)?P((\d+Y)?(\d+M)?(\d+D)?T?(\d+H)?(\d+M)?(\d+S)?)|(\d+W)', value)
  222. if isinstance(value, timedelta):
  223. return value
  224. matches = re.match(r'P(\d+(?:D|W))?T(\d+H)?(\d+M)?(\d+S)?', value)
  225. if not matches:
  226. return False
  227. days = hours = minutes = seconds = 0
  228. _days, _hours, _minutes, _seconds = matches.groups()
  229. if _days:
  230. if _days.endswith("D"):
  231. days = int(_days[:-1])
  232. elif _days.endswith("W"):
  233. days = int(_days[:-1]) * 7
  234. if _hours:
  235. hours = int(_hours[:-1])
  236. if _minutes:
  237. minutes = int(_minutes[:-1])
  238. if _seconds:
  239. seconds = int(_seconds[:-1])
  240. return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
  241. def parse_int(value):
  242. matches = re.match(r'^[\d-]+$', value)
  243. if not matches:
  244. return False
  245. else:
  246. return int(value)
  247. def parse_float(value):
  248. matches = re.match(r'^[\d.-]+$', value)
  249. if not matches:
  250. return False
  251. else:
  252. return float(value)
  253. def parse_boolean(value):
  254. if value == 'true':
  255. return True
  256. else:
  257. return False
  258. def peek(iterable):
  259. """
  260. Peek into an iterable.
  261. """
  262. try:
  263. first = next(iterable)
  264. except StopIteration:
  265. return None
  266. else:
  267. return itertools.chain([first], iterable)
  268. def datetimeformat(value, format=DATETIME_FORMAT):
  269. """
  270. Format a given datetime as a UTC ISO3339 string.
  271. """
  272. if not isinstance(value, datetime):
  273. return value
  274. return value.astimezone(timezone.utc).strftime(format)
  275. def timedeltaformat(value):
  276. """
  277. Format a timedelta to a RFC5545 Duration.
  278. """
  279. if not isinstance(value, timedelta):
  280. return value
  281. days = value.days
  282. hours, seconds = divmod(value.seconds, 3600)
  283. minutes, seconds = divmod(seconds, 60)
  284. formatted = "P"
  285. if days:
  286. formatted += f"{days}D"
  287. if hours or minutes or seconds:
  288. formatted += f"T"
  289. if hours:
  290. formatted += f"{hours}H"
  291. if minutes:
  292. formatted += f"{minutes}M"
  293. if seconds:
  294. formatted += f"{seconds}S"
  295. return formatted
  296. def booleanformat(value):
  297. """
  298. Format a boolean value
  299. """
  300. if isinstance(value, bool):
  301. if value == True:
  302. return "true"
  303. elif value == False:
  304. return "false"
  305. elif value in ("true", "false"):
  306. return value
  307. else:
  308. raise ValueError(f"A boolean value must be provided, not {value}.")
  309. def ensure_bytes(obj):
  310. """
  311. Converts a utf-8 str object to bytes.
  312. """
  313. if isinstance(obj, bytes):
  314. return obj
  315. if isinstance(obj, str):
  316. return bytes(obj, 'utf-8')
  317. else:
  318. raise TypeError("Must be bytes or str")
  319. def ensure_str(obj):
  320. """
  321. Converts bytes to a utf-8 string.
  322. """
  323. if isinstance(obj, str):
  324. return obj
  325. if isinstance(obj, bytes):
  326. return obj.decode('utf-8')
  327. else:
  328. raise TypeError("Must be bytes or str")
  329. def certificate_fingerprint(certificate_str):
  330. """
  331. Calculate the fingerprint for the given certificate, as defined by OpenADR.
  332. """
  333. der_cert = ssl.PEM_cert_to_DER_cert(ensure_str(certificate_str))
  334. hash = hashlib.sha256(der_cert).digest().hex()
  335. return ":".join([hash[i-2:i].upper() for i in range(-20, 0, 2)])
  336. def extract_pem_cert(tree):
  337. """
  338. Extract a given X509 certificate inside an XML tree and return the standard
  339. form of a PEM-encoded certificate.
  340. :param tree lxml.etree: The tree that contains the X509 element. This is
  341. usually the KeyInfo element from the XMLDsig Signature
  342. part of the message.
  343. """
  344. cert = tree.find('.//{http://www.w3.org/2000/09/xmldsig#}X509Certificate').text
  345. return "-----BEGIN CERTIFICATE-----\n" + cert + "-----END CERTIFICATE-----\n"