utils.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. from asyncio import iscoroutine
  2. import xmltodict
  3. from jinja2 import Environment, PackageLoader, select_autoescape
  4. from datetime import datetime, timedelta, timezone
  5. import random
  6. import string
  7. from collections import OrderedDict
  8. import itertools
  9. import re
  10. from .preflight import preflight_message
  11. DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
  12. DATETIME_FORMAT_NO_MICROSECONDS = "%Y-%m-%dT%H:%M:%SZ"
  13. def indent_xml(message):
  14. """
  15. Indents the XML in a nice way.
  16. """
  17. INDENT_SIZE = 2
  18. lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
  19. indent = 0
  20. for i, line in enumerate(lines):
  21. if i == 0:
  22. continue
  23. if re.search(r'^</[^>]+>$', line):
  24. indent = indent - INDENT_SIZE
  25. lines[i] = " " * indent + line
  26. if not (re.search(r'</[^>]+>$', line) or line.endswith("/>")):
  27. indent = indent + INDENT_SIZE
  28. return "\n".join(lines)
  29. def normalize_dict(ordered_dict):
  30. """
  31. Convert the OrderedDict to a regular dict, snake_case the key names, and promote uniform lists.
  32. """
  33. def normalize_key(key):
  34. if key.startswith('oadr'):
  35. key = key[4:]
  36. elif key.startswith('ei'):
  37. key = key[2:]
  38. key = re.sub(r'([a-z])([A-Z])', r'\1_\2', key)
  39. if '-' in key:
  40. key = key.replace('-', '_')
  41. return key.lower()
  42. def parse_datetime(value):
  43. """
  44. Parse an ISO8601 datetime
  45. """
  46. matches = re.match(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.?(\d{1,6})?\d*Z', value)
  47. if matches:
  48. year, month, day, hour, minute, second, microsecond = (int(value) for value in matches.groups())
  49. return datetime(year, month, day, hour, minute, second, microsecond=microsecond, tzinfo=timezone.utc)
  50. else:
  51. print(f"{value} did not match format")
  52. return value
  53. def parse_duration(value):
  54. """
  55. Parse a RFC5545 duration.
  56. """
  57. # 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)
  58. matches = re.match(r'P(\d+(?:D|W))?T(\d+H)?(\d+M)?(\d+S)?', value)
  59. if not matches:
  60. return False
  61. days = hours = minutes = seconds = 0
  62. _days, _hours, _minutes, _seconds = matches.groups()
  63. if _days:
  64. if _days.endswith("D"):
  65. days = int(_days[:-1])
  66. elif _days.endswith("W"):
  67. days = int(_days[:-1]) * 7
  68. if _hours:
  69. hours = int(_hours[:-1])
  70. if _minutes:
  71. minutes = int(_minutes[:-1])
  72. if _seconds:
  73. seconds = int(_seconds[:-1])
  74. return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
  75. def parse_int(value):
  76. matches = re.match(r'^[\d-]+$', value)
  77. if not matches:
  78. return False
  79. else:
  80. return int(value)
  81. def parse_float(value):
  82. matches = re.match(r'^[\d.-]+$', value)
  83. if not matches:
  84. return False
  85. else:
  86. return float(value)
  87. def parse_boolean(value):
  88. if value == 'true':
  89. return True
  90. else:
  91. return False
  92. d = {}
  93. for key, value in ordered_dict.items():
  94. # Interpret values from the dict
  95. if key.startswith("@"):
  96. continue
  97. key = normalize_key(key)
  98. if isinstance(value, OrderedDict):
  99. d[key] = normalize_dict(value)
  100. elif isinstance(value, list):
  101. d[key] = []
  102. for item in value:
  103. if isinstance(item, OrderedDict):
  104. dict_item = normalize_dict(item)
  105. d[key].append(normalize_dict(dict_item))
  106. else:
  107. d[key].append(item)
  108. elif key in ("duration", "startafter", "max_period", "min_period") and isinstance(value, str):
  109. d[key] = parse_duration(value) or value
  110. elif "date_time" in key and isinstance(value, str):
  111. d[key] = parse_datetime(value)
  112. elif value in ('true', 'false'):
  113. d[key] = parse_boolean(value)
  114. elif isinstance(value, str):
  115. d[key] = parse_int(value) or parse_float(value) or value
  116. else:
  117. d[key] = value
  118. # Do our best to make the dictionary structure as pythonic as possible
  119. if key.startswith("x_ei_"):
  120. d[key[5:]] = d.pop(key)
  121. key = key[5:]
  122. # Group all targets as a list of dicts under the key "target"
  123. if key in ("target", "report_subject", "report_data_source"):
  124. targets = d.pop(key)
  125. new_targets = []
  126. if targets:
  127. for ikey in targets:
  128. if isinstance(targets[ikey], list):
  129. new_targets.extend([{ikey: value} for value in targets[ikey]])
  130. else:
  131. new_targets.append({ikey: targets[ikey]})
  132. d[key + "s"] = new_targets
  133. key = key + "s"
  134. # Group all reports as a list of dicts under the key "pending_reports"
  135. if key == "pending_reports":
  136. if isinstance(d[key], dict) and 'report_request_id' in d[key] and isinstance(d[key]['report_request_id'], list):
  137. d['pending_reports'] = [{'request_id': rrid} for rrid in d['pending_reports']['report_request_id']]
  138. # Group all events al a list of dicts under the key "events"
  139. elif key == "event" and isinstance(d[key], list):
  140. events = d.pop("event")
  141. new_events = []
  142. for event in events:
  143. new_event = event['event']
  144. new_event['response_required'] = event['response_required']
  145. new_events.append(new_event)
  146. d["events"] = new_events
  147. # If there's only one event, also put it into a list
  148. elif key == "event" and isinstance(d[key], dict) and "event" in d[key]:
  149. oadr_event = d.pop('event')
  150. ei_event = oadr_event['event']
  151. ei_event['response_required'] = oadr_event['response_required']
  152. d['events'] = [ei_event]
  153. elif key in ("request_event", "created_event") and isinstance(d[key], dict):
  154. d = d[key]
  155. # Plurarize some lists
  156. elif key in ('report_request', 'report'):
  157. if isinstance(d[key], list):
  158. d[key + 's'] = d.pop(key)
  159. else:
  160. d[key + 's'] = [d.pop(key)]
  161. elif key == 'report_description':
  162. if isinstance(d[key], list):
  163. original_descriptions = d.pop(key)
  164. report_descriptions = {}
  165. for item in original_descriptions:
  166. r_id = item.pop('r_id')
  167. report_descriptions[r_id] = item
  168. d[key + 's'] = report_descriptions
  169. else:
  170. original_description = d.pop(key)
  171. r_id = original_description.pop('r_id')
  172. d[key + 's'] = {r_id: original_description}
  173. # Promote the contents of the Qualified Event ID
  174. elif key == "qualified_event_id" and isinstance(d['qualified_event_id'], dict):
  175. qeid = d.pop('qualified_event_id')
  176. d['event_id'] = qeid['event_id']
  177. d['modification_number'] = qeid['modification_number']
  178. # Promote the contents of the tolerance items
  179. # if key == "tolerance" and "tolerate" in d["tolerance"] and len(d["tolerance"]["tolerate"]) == 1:
  180. # d["tolerance"] = d["tolerance"]["tolerate"].values()[0]
  181. # Durations are encapsulated in their own object, remove this nesting
  182. elif isinstance(d[key], dict) and "duration" in d[key] and len(d[key]) == 1:
  183. try:
  184. d[key] = d[key]["duration"]
  185. except:
  186. breakpoint()
  187. # In general, remove all double nesting
  188. elif isinstance(d[key], dict) and key in d[key] and len(d[key]) == 1:
  189. d[key] = d[key][key]
  190. # In general, remove the double nesting of lists of items
  191. elif isinstance(d[key], dict) and key[:-1] in d[key] and len(d[key]) == 1:
  192. if isinstance(d[key][key[:-1]], list):
  193. d[key] = d[key][key[:-1]]
  194. else:
  195. d[key] = [d[key][key[:-1]]]
  196. # Payload values are wrapped in an object according to their type. We don't need that information.
  197. elif key in ("signal_payload", "current_value"):
  198. value = d[key]
  199. if isinstance(d[key], dict):
  200. if 'payload_float' in d[key]:
  201. d[key] = float(d[key]['payload_float']['value'])
  202. elif 'payload_int' in d[key]:
  203. d[key] = int(d[key]['payload_int']['value'])
  204. # All values other than 'false' must be interpreted as True for testEvent (rule 006)
  205. elif key == 'test_event' and not isinstance(d[key], bool):
  206. d[key] = True
  207. # Promote the 'text' item
  208. elif isinstance(d[key], dict) and "text" in d[key] and len(d[key]) == 1:
  209. if key == 'uid':
  210. d[key] = int(d[key]["text"])
  211. else:
  212. d[key] = d[key]["text"]
  213. # Promote a 'date-time' item
  214. elif isinstance(d[key], dict) and "date_time" in d[key] and len(d[key]) == 1:
  215. d[key] = d[key]["date_time"]
  216. # Promote 'properties' item
  217. elif isinstance(d[key], dict) and "properties" in d[key] and len(d[key]) == 1:
  218. d[key] = d[key]["properties"]
  219. # Remove all empty dicts
  220. elif isinstance(d[key], dict) and len(d[key]) == 0:
  221. d.pop(key)
  222. return d
  223. def parse_message(data):
  224. """
  225. Parse a message and distill its usable parts. Returns a message type and payload.
  226. """
  227. message_dict = xmltodict.parse(data, process_namespaces=True, namespaces=NAMESPACES)
  228. message_type, message_payload = message_dict['oadrPayload']['oadrSignedObject'].popitem()
  229. return message_type, normalize_dict(message_payload)
  230. def create_message(message_type, **message_payload):
  231. preflight_message(message_type, message_payload)
  232. template = TEMPLATES.get_template(f'{message_type}.xml')
  233. return indent_xml(template.render(**message_payload))
  234. def new_request_id(*args, **kwargs):
  235. return random.choice(string.ascii_lowercase) + ''.join(random.choice(string.hexdigits) for _ in range(9)).lower()
  236. def generate_id(*args, **kwargs):
  237. return new_request_id()
  238. def peek(iterable):
  239. """
  240. Peek into an iterable.
  241. """
  242. try:
  243. first = next(iterable)
  244. except StopIteration:
  245. return None
  246. else:
  247. return itertools.chain([first], iterable)
  248. def datetimeformat(value, format=DATETIME_FORMAT):
  249. if not isinstance(value, datetime):
  250. return value
  251. return value.astimezone(timezone.utc).strftime(format)
  252. def timedeltaformat(value):
  253. """
  254. Format a timedelta to a RFC5545 Duration.
  255. """
  256. if not isinstance(value, timedelta):
  257. return value
  258. days = value.days
  259. hours, seconds = divmod(value.seconds, 3600)
  260. minutes, seconds = divmod(seconds, 60)
  261. formatted = "P"
  262. if days:
  263. formatted += f"{days}D"
  264. if hours or minutes or seconds:
  265. formatted += f"T"
  266. if hours:
  267. formatted += f"{hours}H"
  268. if minutes:
  269. formatted += f"{minutes}M"
  270. if seconds:
  271. formatted += f"{seconds}S"
  272. return formatted
  273. def booleanformat(value):
  274. """
  275. Format a boolean value
  276. """
  277. if isinstance(value, bool):
  278. if value == True:
  279. return "true"
  280. elif value == False:
  281. return "false"
  282. else:
  283. return value
  284. TEMPLATES = Environment(loader=PackageLoader('pyopenadr', 'templates'))
  285. NAMESPACES = {
  286. 'http://docs.oasis-open.org/ns/energyinterop/201110': None,
  287. 'http://openadr.org/oadr-2.0b/2012/07': None,
  288. 'urn:ietf:params:xml:ns:icalendar-2.0': None,
  289. 'http://docs.oasis-open.org/ns/energyinterop/201110/payloads': None,
  290. 'http://docs.oasis-open.org/ns/emix/2011/06': None,
  291. 'urn:ietf:params:xml:ns:icalendar-2.0:stream': None,
  292. 'http://docs.oasis-open.org/ns/emix/2011/06/power': None,
  293. 'http://docs.oasis-open.org/ns/emix/2011/06/siscale': None
  294. }
  295. TEMPLATES.filters['datetimeformat'] = datetimeformat
  296. TEMPLATES.filters['timedeltaformat'] = timedeltaformat
  297. TEMPLATES.filters['booleanformat'] = booleanformat