utils.py 12 KB

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