utils.py 9.3 KB

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