arthexis 0.1.13__py3-none-any.whl → 0.1.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (107) hide show
  1. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/METADATA +222 -221
  2. arthexis-0.1.14.dist-info/RECORD +109 -0
  3. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +43 -43
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -32
  9. config/context_processors.py +67 -69
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +25 -25
  14. config/offline.py +49 -49
  15. config/settings.py +691 -682
  16. config/settings_helpers.py +109 -109
  17. config/urls.py +171 -166
  18. config/wsgi.py +17 -17
  19. core/admin.py +3771 -2809
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +151 -151
  22. core/apps.py +356 -272
  23. core/auto_upgrade.py +57 -57
  24. core/backends.py +265 -236
  25. core/changelog.py +342 -0
  26. core/entity.py +133 -133
  27. core/environment.py +61 -61
  28. core/fields.py +168 -168
  29. core/form_fields.py +75 -75
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +178 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +100 -100
  36. core/mailer.py +85 -85
  37. core/middleware.py +91 -91
  38. core/models.py +3609 -2795
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +108 -108
  42. core/release.py +721 -368
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -149
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +315 -315
  47. core/system.py +752 -493
  48. core/tasks.py +408 -394
  49. core/temp_passwords.py +181 -181
  50. core/test_system_info.py +186 -139
  51. core/tests.py +2095 -1521
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +641 -633
  55. core/views.py +2175 -1417
  56. core/widgets.py +213 -94
  57. core/workgroup_urls.py +17 -17
  58. core/workgroup_views.py +94 -94
  59. nodes/admin.py +1720 -1161
  60. nodes/apps.py +87 -85
  61. nodes/backends.py +160 -160
  62. nodes/dns.py +203 -203
  63. nodes/feature_checks.py +133 -133
  64. nodes/lcd.py +165 -165
  65. nodes/models.py +1737 -1597
  66. nodes/reports.py +411 -411
  67. nodes/rfid_sync.py +195 -0
  68. nodes/signals.py +18 -0
  69. nodes/tasks.py +46 -46
  70. nodes/tests.py +3810 -3116
  71. nodes/urls.py +15 -14
  72. nodes/utils.py +121 -105
  73. nodes/views.py +683 -619
  74. ocpp/admin.py +948 -948
  75. ocpp/apps.py +25 -25
  76. ocpp/consumers.py +1565 -1459
  77. ocpp/evcs.py +844 -844
  78. ocpp/evcs_discovery.py +158 -158
  79. ocpp/models.py +917 -917
  80. ocpp/reference_utils.py +42 -42
  81. ocpp/routing.py +11 -11
  82. ocpp/simulator.py +745 -745
  83. ocpp/status_display.py +26 -26
  84. ocpp/store.py +601 -541
  85. ocpp/tasks.py +31 -31
  86. ocpp/test_export_import.py +130 -130
  87. ocpp/test_rfid.py +913 -702
  88. ocpp/tests.py +4445 -4094
  89. ocpp/transactions_io.py +189 -189
  90. ocpp/urls.py +50 -50
  91. ocpp/views.py +1479 -1251
  92. pages/admin.py +708 -539
  93. pages/apps.py +10 -10
  94. pages/checks.py +40 -40
  95. pages/context_processors.py +127 -119
  96. pages/defaults.py +13 -13
  97. pages/forms.py +198 -198
  98. pages/middleware.py +205 -153
  99. pages/models.py +607 -426
  100. pages/tests.py +2612 -2200
  101. pages/urls.py +25 -25
  102. pages/utils.py +12 -12
  103. pages/views.py +1165 -1128
  104. arthexis-0.1.13.dist-info/RECORD +0 -105
  105. nodes/actions.py +0 -70
  106. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/WHEEL +0 -0
  107. {arthexis-0.1.13.dist-info → arthexis-0.1.14.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -1,1459 +1,1565 @@
1
- import base64
2
- import ipaddress
3
- import re
4
- from datetime import datetime
5
- import asyncio
6
- import inspect
7
- import json
8
- from urllib.parse import parse_qs
9
- from django.utils import timezone
10
- from core.models import EnergyAccount, Reference, RFID as CoreRFID
11
- from nodes.models import NetMessage
12
- from django.core.exceptions import ValidationError
13
-
14
- from channels.generic.websocket import AsyncWebsocketConsumer
15
- from channels.db import database_sync_to_async
16
- from asgiref.sync import sync_to_async
17
- from config.offline import requires_network
18
-
19
- from . import store
20
- from decimal import Decimal
21
- from django.utils.dateparse import parse_datetime
22
- from .models import Transaction, Charger, MeterValue, DataTransferMessage
23
- from .reference_utils import host_is_local_loopback
24
- from .evcs_discovery import (
25
- DEFAULT_CONSOLE_PORT,
26
- HTTPS_PORTS,
27
- build_console_url,
28
- prioritise_ports,
29
- scan_open_ports,
30
- )
31
-
32
- FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
-
34
-
35
- # Query parameter keys that may contain the charge point serial. Keys are
36
- # matched case-insensitively and trimmed before use.
37
- SERIAL_QUERY_PARAM_NAMES = (
38
- "cid",
39
- "chargepointid",
40
- "charge_point_id",
41
- "chargeboxid",
42
- "charge_box_id",
43
- "chargerid",
44
- )
45
-
46
-
47
- def _parse_ip(value: str | None):
48
- """Return an :mod:`ipaddress` object for the provided value, if valid."""
49
-
50
- candidate = (value or "").strip()
51
- if not candidate or candidate.lower() == "unknown":
52
- return None
53
- if candidate.lower().startswith("for="):
54
- candidate = candidate[4:].strip()
55
- candidate = candidate.strip("'\"")
56
- if candidate.startswith("["):
57
- closing = candidate.find("]")
58
- if closing != -1:
59
- candidate = candidate[1:closing]
60
- else:
61
- candidate = candidate[1:]
62
- # Remove any comma separated values that may remain.
63
- if "," in candidate:
64
- candidate = candidate.split(",", 1)[0].strip()
65
- try:
66
- parsed = ipaddress.ip_address(candidate)
67
- except ValueError:
68
- host, sep, maybe_port = candidate.rpartition(":")
69
- if not sep or not maybe_port.isdigit():
70
- return None
71
- try:
72
- parsed = ipaddress.ip_address(host)
73
- except ValueError:
74
- return None
75
- return parsed
76
-
77
-
78
- def _resolve_client_ip(scope: dict) -> str | None:
79
- """Return the most useful client IP for the provided ASGI scope."""
80
-
81
- headers = scope.get("headers") or []
82
- header_map: dict[str, list[str]] = {}
83
- for key_bytes, value_bytes in headers:
84
- try:
85
- key = key_bytes.decode("latin1").lower()
86
- except Exception:
87
- continue
88
- try:
89
- value = value_bytes.decode("latin1")
90
- except Exception:
91
- value = ""
92
- header_map.setdefault(key, []).append(value)
93
-
94
- candidates: list[str] = []
95
- for raw in header_map.get("x-forwarded-for", []):
96
- candidates.extend(part.strip() for part in raw.split(","))
97
- for raw in header_map.get("forwarded", []):
98
- for segment in raw.split(","):
99
- match = FORWARDED_PAIR_RE.search(segment)
100
- if match:
101
- candidates.append(match.group("value"))
102
- candidates.extend(header_map.get("x-real-ip", []))
103
- client = scope.get("client")
104
- if client:
105
- candidates.append((client[0] or "").strip())
106
-
107
- fallback: str | None = None
108
- for raw in candidates:
109
- parsed = _parse_ip(raw)
110
- if not parsed:
111
- continue
112
- ip_text = str(parsed)
113
- if parsed.is_loopback:
114
- if fallback is None:
115
- fallback = ip_text
116
- continue
117
- return ip_text
118
- return fallback
119
-
120
-
121
- def _parse_ocpp_timestamp(value) -> datetime | None:
122
- """Return an aware :class:`~datetime.datetime` for OCPP timestamps."""
123
-
124
- if not value:
125
- return None
126
- if isinstance(value, datetime):
127
- timestamp = value
128
- else:
129
- timestamp = parse_datetime(str(value))
130
- if not timestamp:
131
- return None
132
- if timezone.is_naive(timestamp):
133
- timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
134
- return timestamp
135
-
136
-
137
- class SinkConsumer(AsyncWebsocketConsumer):
138
- """Accept any message without validation."""
139
-
140
- @requires_network
141
- async def connect(self) -> None:
142
- self.client_ip = _resolve_client_ip(self.scope)
143
- if not store.register_ip_connection(self.client_ip, self):
144
- await self.close(code=4003)
145
- return
146
- await self.accept()
147
-
148
- async def disconnect(self, close_code):
149
- store.release_ip_connection(getattr(self, "client_ip", None), self)
150
-
151
- async def receive(
152
- self, text_data: str | None = None, bytes_data: bytes | None = None
153
- ) -> None:
154
- if text_data is None:
155
- return
156
- try:
157
- msg = json.loads(text_data)
158
- if isinstance(msg, list) and msg and msg[0] == 2:
159
- await self.send(json.dumps([3, msg[1], {}]))
160
- except Exception:
161
- pass
162
-
163
-
164
- class CSMSConsumer(AsyncWebsocketConsumer):
165
- """Very small subset of OCPP 1.6 CSMS behaviour."""
166
-
167
- consumption_update_interval = 300
168
-
169
- def _extract_serial_identifier(self) -> str:
170
- """Return the charge point serial from the query string or path."""
171
-
172
- self.serial_source = None
173
- query_bytes = self.scope.get("query_string") or b""
174
- self._raw_query_string = query_bytes.decode("utf-8", "ignore") if query_bytes else ""
175
- if query_bytes:
176
- try:
177
- parsed = parse_qs(
178
- self._raw_query_string,
179
- keep_blank_values=False,
180
- )
181
- except Exception:
182
- parsed = {}
183
- if parsed:
184
- normalized = {
185
- key.lower(): values for key, values in parsed.items() if values
186
- }
187
- for candidate in SERIAL_QUERY_PARAM_NAMES:
188
- values = normalized.get(candidate)
189
- if not values:
190
- continue
191
- for value in values:
192
- if not value:
193
- continue
194
- trimmed = value.strip()
195
- if trimmed:
196
- return trimmed
197
-
198
- return self.scope["url_route"]["kwargs"].get("cid", "")
199
-
200
- @requires_network
201
- async def connect(self):
202
- raw_serial = self._extract_serial_identifier()
203
- try:
204
- self.charger_id = Charger.validate_serial(raw_serial)
205
- except ValidationError as exc:
206
- serial = Charger.normalize_serial(raw_serial)
207
- store_key = store.pending_key(serial)
208
- message = exc.messages[0] if exc.messages else "Invalid Serial Number"
209
- details: list[str] = []
210
- if getattr(self, "serial_source", None):
211
- details.append(f"serial_source={self.serial_source}")
212
- if getattr(self, "_raw_query_string", ""):
213
- details.append(f"query_string={self._raw_query_string!r}")
214
- if details:
215
- message = f"{message} ({'; '.join(details)})"
216
- store.add_log(
217
- store_key,
218
- f"Rejected connection: {message}",
219
- log_type="charger",
220
- )
221
- await self.close(code=4003)
222
- return
223
- self.connector_value: int | None = None
224
- self.store_key = store.pending_key(self.charger_id)
225
- self.aggregate_charger: Charger | None = None
226
- self._consumption_task: asyncio.Task | None = None
227
- self._consumption_message_uuid: str | None = None
228
- subprotocol = None
229
- offered = self.scope.get("subprotocols", [])
230
- if "ocpp1.6" in offered:
231
- subprotocol = "ocpp1.6"
232
- self.client_ip = _resolve_client_ip(self.scope)
233
- self._header_reference_created = False
234
- # Close any pending connection for this charger so reconnections do
235
- # not leak stale consumers when the connector id has not been
236
- # negotiated yet.
237
- existing = store.connections.get(self.store_key)
238
- if existing is not None:
239
- store.release_ip_connection(getattr(existing, "client_ip", None), existing)
240
- await existing.close()
241
- if not store.register_ip_connection(self.client_ip, self):
242
- store.add_log(
243
- self.store_key,
244
- f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
245
- log_type="charger",
246
- )
247
- await self.close(code=4003)
248
- return
249
- await self.accept(subprotocol=subprotocol)
250
- store.add_log(
251
- self.store_key,
252
- f"Connected (subprotocol={subprotocol or 'none'})",
253
- log_type="charger",
254
- )
255
- store.connections[self.store_key] = self
256
- store.logs["charger"].setdefault(self.store_key, [])
257
- self.charger, created = await database_sync_to_async(
258
- Charger.objects.get_or_create
259
- )(
260
- charger_id=self.charger_id,
261
- connector_id=None,
262
- defaults={"last_path": self.scope.get("path", "")},
263
- )
264
- await database_sync_to_async(self.charger.refresh_manager_node)()
265
- self.aggregate_charger = self.charger
266
- location_name = await sync_to_async(
267
- lambda: self.charger.location.name if self.charger.location else ""
268
- )()
269
- friendly_name = location_name or self.charger_id
270
- store.register_log_name(self.store_key, friendly_name, log_type="charger")
271
- store.register_log_name(self.charger_id, friendly_name, log_type="charger")
272
- store.register_log_name(
273
- store.identity_key(self.charger_id, None),
274
- friendly_name,
275
- log_type="charger",
276
- )
277
-
278
- async def _get_account(self, id_tag: str) -> EnergyAccount | None:
279
- """Return the energy account for the provided RFID if valid."""
280
- if not id_tag:
281
- return None
282
- return await database_sync_to_async(
283
- EnergyAccount.objects.filter(
284
- rfids__rfid=id_tag.upper(), rfids__allowed=True
285
- ).first
286
- )()
287
-
288
- async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
289
- """Ensure an RFID record exists and update its last seen timestamp."""
290
-
291
- if not id_tag:
292
- return None
293
-
294
- normalized = id_tag.upper()
295
-
296
- def _ensure() -> CoreRFID:
297
- now = timezone.now()
298
- tag, created = CoreRFID.objects.get_or_create(
299
- rfid=normalized,
300
- defaults={"allowed": False, "last_seen_on": now},
301
- )
302
- if created:
303
- updates = []
304
- if tag.allowed:
305
- tag.allowed = False
306
- updates.append("allowed")
307
- if tag.last_seen_on != now:
308
- tag.last_seen_on = now
309
- updates.append("last_seen_on")
310
- if updates:
311
- tag.save(update_fields=updates)
312
- else:
313
- tag.last_seen_on = now
314
- tag.save(update_fields=["last_seen_on"])
315
- return tag
316
-
317
- return await database_sync_to_async(_ensure)()
318
-
319
- async def _assign_connector(self, connector: int | str | None) -> None:
320
- """Ensure ``self.charger`` matches the provided connector id."""
321
- if connector in (None, "", "-"):
322
- connector_value = None
323
- else:
324
- try:
325
- connector_value = int(connector)
326
- if connector_value == 0:
327
- connector_value = None
328
- except (TypeError, ValueError):
329
- return
330
- if connector_value is None:
331
- if not self._header_reference_created and self.client_ip:
332
- await database_sync_to_async(self._ensure_console_reference)()
333
- self._header_reference_created = True
334
- return
335
- if (
336
- self.connector_value == connector_value
337
- and self.charger.connector_id == connector_value
338
- ):
339
- return
340
- if (
341
- not self.aggregate_charger
342
- or self.aggregate_charger.connector_id is not None
343
- ):
344
- aggregate, _ = await database_sync_to_async(
345
- Charger.objects.get_or_create
346
- )(
347
- charger_id=self.charger_id,
348
- connector_id=None,
349
- defaults={"last_path": self.scope.get("path", "")},
350
- )
351
- await database_sync_to_async(aggregate.refresh_manager_node)()
352
- self.aggregate_charger = aggregate
353
- existing = await database_sync_to_async(
354
- Charger.objects.filter(
355
- charger_id=self.charger_id, connector_id=connector_value
356
- ).first
357
- )()
358
- if existing:
359
- self.charger = existing
360
- await database_sync_to_async(self.charger.refresh_manager_node)()
361
- else:
362
-
363
- def _create_connector():
364
- charger, _ = Charger.objects.get_or_create(
365
- charger_id=self.charger_id,
366
- connector_id=connector_value,
367
- defaults={"last_path": self.scope.get("path", "")},
368
- )
369
- if self.scope.get("path") and charger.last_path != self.scope.get(
370
- "path"
371
- ):
372
- charger.last_path = self.scope.get("path")
373
- charger.save(update_fields=["last_path"])
374
- charger.refresh_manager_node()
375
- return charger
376
-
377
- self.charger = await database_sync_to_async(_create_connector)()
378
- previous_key = self.store_key
379
- new_key = store.identity_key(self.charger_id, connector_value)
380
- if previous_key != new_key:
381
- existing_consumer = store.connections.get(new_key)
382
- if existing_consumer is not None and existing_consumer is not self:
383
- await existing_consumer.close()
384
- store.reassign_identity(previous_key, new_key)
385
- store.connections[new_key] = self
386
- store.logs["charger"].setdefault(new_key, [])
387
- connector_name = await sync_to_async(
388
- lambda: self.charger.name or self.charger.charger_id
389
- )()
390
- store.register_log_name(new_key, connector_name, log_type="charger")
391
- aggregate_name = ""
392
- if self.aggregate_charger:
393
- aggregate_name = await sync_to_async(
394
- lambda: self.aggregate_charger.name or self.aggregate_charger.charger_id
395
- )()
396
- store.register_log_name(
397
- store.identity_key(self.charger_id, None),
398
- aggregate_name or self.charger_id,
399
- log_type="charger",
400
- )
401
- self.store_key = new_key
402
- self.connector_value = connector_value
403
-
404
- def _ensure_console_reference(self) -> None:
405
- """Create or update a header reference for the connected charger."""
406
-
407
- ip = (self.client_ip or "").strip()
408
- serial = (self.charger_id or "").strip()
409
- if not ip or not serial:
410
- return
411
- if host_is_local_loopback(ip):
412
- return
413
- host = ip
414
- ports = scan_open_ports(host)
415
- if ports:
416
- ordered_ports = prioritise_ports(ports)
417
- else:
418
- ordered_ports = prioritise_ports([DEFAULT_CONSOLE_PORT])
419
- port = ordered_ports[0] if ordered_ports else DEFAULT_CONSOLE_PORT
420
- secure = port in HTTPS_PORTS
421
- url = build_console_url(host, port, secure)
422
- alt_text = f"{serial} Console"
423
- reference = Reference.objects.filter(alt_text=alt_text).order_by("id").first()
424
- if reference is None:
425
- reference = Reference.objects.create(
426
- alt_text=alt_text,
427
- value=url,
428
- show_in_header=True,
429
- method="link",
430
- )
431
- updated_fields: list[str] = []
432
- if reference.value != url:
433
- reference.value = url
434
- updated_fields.append("value")
435
- if reference.method != "link":
436
- reference.method = "link"
437
- updated_fields.append("method")
438
- if not reference.show_in_header:
439
- reference.show_in_header = True
440
- updated_fields.append("show_in_header")
441
- if updated_fields:
442
- reference.save(update_fields=updated_fields)
443
-
444
- async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
445
- """Parse a MeterValues payload into MeterValue rows."""
446
- connector_raw = payload.get("connectorId")
447
- connector_value = None
448
- if connector_raw is not None:
449
- try:
450
- connector_value = int(connector_raw)
451
- except (TypeError, ValueError):
452
- connector_value = None
453
- await self._assign_connector(connector_value)
454
- tx_id = payload.get("transactionId")
455
- tx_obj = None
456
- if tx_id is not None:
457
- tx_obj = store.transactions.get(self.store_key)
458
- if not tx_obj or tx_obj.pk != int(tx_id):
459
- tx_obj = await database_sync_to_async(
460
- Transaction.objects.filter(pk=tx_id, charger=self.charger).first
461
- )()
462
- if tx_obj is None:
463
- tx_obj = await database_sync_to_async(Transaction.objects.create)(
464
- pk=tx_id, charger=self.charger, start_time=timezone.now()
465
- )
466
- store.start_session_log(self.store_key, tx_obj.pk)
467
- store.add_session_message(self.store_key, raw_message)
468
- store.transactions[self.store_key] = tx_obj
469
- else:
470
- tx_obj = store.transactions.get(self.store_key)
471
-
472
- readings = []
473
- updated_fields: set[str] = set()
474
- temperature = None
475
- temp_unit = ""
476
- for mv in payload.get("meterValue", []):
477
- ts = parse_datetime(mv.get("timestamp"))
478
- values: dict[str, Decimal] = {}
479
- context = ""
480
- for sv in mv.get("sampledValue", []):
481
- try:
482
- val = Decimal(str(sv.get("value")))
483
- except Exception:
484
- continue
485
- context = sv.get("context", context or "")
486
- measurand = sv.get("measurand", "")
487
- unit = sv.get("unit", "")
488
- field = None
489
- if measurand in ("", "Energy.Active.Import.Register"):
490
- field = "energy"
491
- if unit == "Wh":
492
- val = val / Decimal("1000")
493
- elif measurand == "Voltage":
494
- field = "voltage"
495
- elif measurand == "Current.Import":
496
- field = "current_import"
497
- elif measurand == "Current.Offered":
498
- field = "current_offered"
499
- elif measurand == "Temperature":
500
- field = "temperature"
501
- temperature = val
502
- temp_unit = unit
503
- elif measurand == "SoC":
504
- field = "soc"
505
- if field:
506
- if tx_obj and context in ("Transaction.Begin", "Transaction.End"):
507
- suffix = "start" if context == "Transaction.Begin" else "stop"
508
- if field == "energy":
509
- mult = 1000 if unit in ("kW", "kWh") else 1
510
- setattr(tx_obj, f"meter_{suffix}", int(val * mult))
511
- updated_fields.add(f"meter_{suffix}")
512
- else:
513
- setattr(tx_obj, f"{field}_{suffix}", val)
514
- updated_fields.add(f"{field}_{suffix}")
515
- else:
516
- values[field] = val
517
- if tx_obj and field == "energy" and tx_obj.meter_start is None:
518
- mult = 1000 if unit in ("kW", "kWh") else 1
519
- try:
520
- tx_obj.meter_start = int(val * mult)
521
- except (TypeError, ValueError):
522
- pass
523
- else:
524
- updated_fields.add("meter_start")
525
- if values and context not in ("Transaction.Begin", "Transaction.End"):
526
- readings.append(
527
- MeterValue(
528
- charger=self.charger,
529
- connector_id=connector_value,
530
- transaction=tx_obj,
531
- timestamp=ts,
532
- context=context,
533
- **values,
534
- )
535
- )
536
- if readings:
537
- await database_sync_to_async(MeterValue.objects.bulk_create)(readings)
538
- if tx_obj and updated_fields:
539
- await database_sync_to_async(tx_obj.save)(
540
- update_fields=list(updated_fields)
541
- )
542
- if connector_value is not None and not self.charger.connector_id:
543
- self.charger.connector_id = connector_value
544
- await database_sync_to_async(self.charger.save)(
545
- update_fields=["connector_id"]
546
- )
547
- if temperature is not None:
548
- self.charger.temperature = temperature
549
- self.charger.temperature_unit = temp_unit
550
- await database_sync_to_async(self.charger.save)(
551
- update_fields=["temperature", "temperature_unit"]
552
- )
553
-
554
- async def _update_firmware_state(
555
- self, status: str, status_info: str, timestamp: datetime | None
556
- ) -> None:
557
- """Persist firmware status fields for the active charger identities."""
558
-
559
- targets: list[Charger] = []
560
- seen_ids: set[int] = set()
561
- for charger in (self.charger, self.aggregate_charger):
562
- if not charger or charger.pk is None:
563
- continue
564
- if charger.pk in seen_ids:
565
- continue
566
- targets.append(charger)
567
- seen_ids.add(charger.pk)
568
-
569
- if not targets:
570
- return
571
-
572
- def _persist(ids: list[int]) -> None:
573
- Charger.objects.filter(pk__in=ids).update(
574
- firmware_status=status,
575
- firmware_status_info=status_info,
576
- firmware_timestamp=timestamp,
577
- )
578
-
579
- await database_sync_to_async(_persist)([target.pk for target in targets])
580
- for target in targets:
581
- target.firmware_status = status
582
- target.firmware_status_info = status_info
583
- target.firmware_timestamp = timestamp
584
-
585
- async def _cancel_consumption_message(self) -> None:
586
- """Stop any scheduled consumption message updates."""
587
-
588
- task = self._consumption_task
589
- self._consumption_task = None
590
- if task:
591
- task.cancel()
592
- try:
593
- await task
594
- except asyncio.CancelledError:
595
- pass
596
- self._consumption_message_uuid = None
597
-
598
- async def _update_consumption_message(self, tx_id: int) -> str | None:
599
- """Create or update the Net Message for an active transaction."""
600
-
601
- existing_uuid = self._consumption_message_uuid
602
-
603
- def _persist() -> str | None:
604
- tx = (
605
- Transaction.objects.select_related("charger")
606
- .filter(pk=tx_id)
607
- .first()
608
- )
609
- if not tx:
610
- return None
611
- charger = tx.charger or self.charger
612
- serial = ""
613
- if charger and charger.charger_id:
614
- serial = charger.charger_id
615
- elif self.charger_id:
616
- serial = self.charger_id
617
- serial = serial[:64]
618
- if not serial:
619
- return None
620
- now_local = timezone.localtime(timezone.now())
621
- body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
622
- if existing_uuid:
623
- msg = NetMessage.objects.filter(uuid=existing_uuid).first()
624
- if msg:
625
- msg.subject = serial
626
- msg.body = body_value
627
- msg.save(update_fields=["subject", "body"])
628
- msg.propagate()
629
- return str(msg.uuid)
630
- msg = NetMessage.broadcast(subject=serial, body=body_value)
631
- return str(msg.uuid)
632
-
633
- try:
634
- result = await database_sync_to_async(_persist)()
635
- except Exception as exc: # pragma: no cover - unexpected errors
636
- store.add_log(
637
- self.store_key,
638
- f"Failed to broadcast consumption message: {exc}",
639
- log_type="charger",
640
- )
641
- return None
642
- if result is None:
643
- store.add_log(
644
- self.store_key,
645
- "Unable to broadcast consumption message: missing data",
646
- log_type="charger",
647
- )
648
- return None
649
- self._consumption_message_uuid = result
650
- return result
651
-
652
- async def _consumption_message_loop(self, tx_id: int) -> None:
653
- """Periodically refresh the consumption Net Message."""
654
-
655
- try:
656
- while True:
657
- await asyncio.sleep(self.consumption_update_interval)
658
- updated = await self._update_consumption_message(tx_id)
659
- if not updated:
660
- break
661
- except asyncio.CancelledError:
662
- pass
663
- except Exception as exc: # pragma: no cover - unexpected errors
664
- store.add_log(
665
- self.store_key,
666
- f"Failed to refresh consumption message: {exc}",
667
- log_type="charger",
668
- )
669
-
670
- async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
671
- """Send the initial consumption message and schedule updates."""
672
-
673
- await self._cancel_consumption_message()
674
- initial = await self._update_consumption_message(tx_obj.pk)
675
- if not initial:
676
- return
677
- task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
678
- task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
679
- self._consumption_task = task
680
-
681
- async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
682
- metadata = store.pop_pending_call(message_id)
683
- if not metadata:
684
- return
685
- if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
686
- return
687
- action = metadata.get("action")
688
- log_key = metadata.get("log_key") or self.store_key
689
- if action == "DataTransfer":
690
- message_pk = metadata.get("message_pk")
691
- if not message_pk:
692
- return
693
-
694
- def _apply():
695
- message = DataTransferMessage.objects.filter(pk=message_pk).first()
696
- if not message:
697
- return
698
- status_value = str((payload or {}).get("status") or "").strip()
699
- message.status = status_value
700
- message.response_data = (payload or {}).get("data")
701
- message.error_code = ""
702
- message.error_description = ""
703
- message.error_details = None
704
- message.responded_at = timezone.now()
705
- message.save(
706
- update_fields=[
707
- "status",
708
- "response_data",
709
- "error_code",
710
- "error_description",
711
- "error_details",
712
- "responded_at",
713
- "updated_at",
714
- ]
715
- )
716
-
717
- await database_sync_to_async(_apply)()
718
- return
719
- if action == "GetConfiguration":
720
- payload_data = payload if isinstance(payload, dict) else {}
721
- try:
722
- payload_text = json.dumps(
723
- payload_data, sort_keys=True, ensure_ascii=False
724
- )
725
- except TypeError:
726
- payload_text = str(payload_data)
727
- store.add_log(
728
- log_key,
729
- f"GetConfiguration result: {payload_text}",
730
- log_type="charger",
731
- )
732
- return
733
- if action == "TriggerMessage":
734
- payload_data = payload if isinstance(payload, dict) else {}
735
- status_value = str(payload_data.get("status") or "").strip()
736
- target = metadata.get("trigger_target") or metadata.get("follow_up_action")
737
- connector_value = metadata.get("trigger_connector")
738
- message = "TriggerMessage result"
739
- if target:
740
- message = f"TriggerMessage {target} result"
741
- if status_value:
742
- message += f": status={status_value}"
743
- if connector_value:
744
- message += f", connector={connector_value}"
745
- store.add_log(log_key, message, log_type="charger")
746
- if status_value == "Accepted" and target:
747
- store.register_triggered_followup(
748
- self.charger_id,
749
- str(target),
750
- connector=connector_value,
751
- log_key=log_key,
752
- target=str(target),
753
- )
754
- return
755
- if action == "RemoteStartTransaction":
756
- payload_data = payload if isinstance(payload, dict) else {}
757
- status_value = str(payload_data.get("status") or "").strip()
758
- message = "RemoteStartTransaction result"
759
- if status_value:
760
- message += f": status={status_value}"
761
- store.add_log(log_key, message, log_type="charger")
762
- return
763
- if action == "RemoteStopTransaction":
764
- payload_data = payload if isinstance(payload, dict) else {}
765
- status_value = str(payload_data.get("status") or "").strip()
766
- message = "RemoteStopTransaction result"
767
- if status_value:
768
- message += f": status={status_value}"
769
- store.add_log(log_key, message, log_type="charger")
770
- return
771
- if action == "Reset":
772
- payload_data = payload if isinstance(payload, dict) else {}
773
- status_value = str(payload_data.get("status") or "").strip()
774
- message = "Reset result"
775
- if status_value:
776
- message += f": status={status_value}"
777
- store.add_log(log_key, message, log_type="charger")
778
- return
779
- if action != "ChangeAvailability":
780
- return
781
- status = str((payload or {}).get("status") or "").strip()
782
- requested_type = metadata.get("availability_type")
783
- connector_value = metadata.get("connector_id")
784
- requested_at = metadata.get("requested_at")
785
- await self._update_change_availability_state(
786
- connector_value,
787
- requested_type,
788
- status,
789
- requested_at,
790
- details="",
791
- )
792
-
793
- async def _handle_call_error(
794
- self,
795
- message_id: str,
796
- error_code: str | None,
797
- description: str | None,
798
- details: dict | None,
799
- ) -> None:
800
- metadata = store.pop_pending_call(message_id)
801
- if not metadata:
802
- return
803
- if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
804
- return
805
- action = metadata.get("action")
806
- log_key = metadata.get("log_key") or self.store_key
807
- if action == "DataTransfer":
808
- message_pk = metadata.get("message_pk")
809
- if not message_pk:
810
- return
811
-
812
- def _apply():
813
- message = DataTransferMessage.objects.filter(pk=message_pk).first()
814
- if not message:
815
- return
816
- status_value = (error_code or "Error").strip() or "Error"
817
- message.status = status_value
818
- message.response_data = None
819
- message.error_code = (error_code or "").strip()
820
- message.error_description = (description or "").strip()
821
- message.error_details = details
822
- message.responded_at = timezone.now()
823
- message.save(
824
- update_fields=[
825
- "status",
826
- "response_data",
827
- "error_code",
828
- "error_description",
829
- "error_details",
830
- "responded_at",
831
- "updated_at",
832
- ]
833
- )
834
-
835
- await database_sync_to_async(_apply)()
836
- return
837
- if action == "GetConfiguration":
838
- parts: list[str] = []
839
- code_text = (error_code or "").strip()
840
- if code_text:
841
- parts.append(f"code={code_text}")
842
- description_text = (description or "").strip()
843
- if description_text:
844
- parts.append(f"description={description_text}")
845
- if details:
846
- try:
847
- details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
848
- except TypeError:
849
- details_text = str(details)
850
- if details_text:
851
- parts.append(f"details={details_text}")
852
- if parts:
853
- message = "GetConfiguration error: " + ", ".join(parts)
854
- else:
855
- message = "GetConfiguration error"
856
- store.add_log(log_key, message, log_type="charger")
857
- return
858
- if action == "TriggerMessage":
859
- target = metadata.get("trigger_target") or metadata.get("follow_up_action")
860
- connector_value = metadata.get("trigger_connector")
861
- parts: list[str] = []
862
- if error_code:
863
- parts.append(f"code={str(error_code).strip()}")
864
- if description:
865
- parts.append(f"description={str(description).strip()}")
866
- if details:
867
- try:
868
- parts.append(
869
- "details="
870
- + json.dumps(details, sort_keys=True, ensure_ascii=False)
871
- )
872
- except TypeError:
873
- parts.append(f"details={details}")
874
- label = f"TriggerMessage {target}" if target else "TriggerMessage"
875
- message = label + " error"
876
- if parts:
877
- message += ": " + ", ".join(parts)
878
- if connector_value:
879
- message += f", connector={connector_value}"
880
- store.add_log(log_key, message, log_type="charger")
881
- return
882
- if action == "RemoteStartTransaction":
883
- message = "RemoteStartTransaction error"
884
- if error_code:
885
- message += f": code={str(error_code).strip()}"
886
- if description:
887
- suffix = str(description).strip()
888
- if suffix:
889
- message += f", description={suffix}"
890
- store.add_log(log_key, message, log_type="charger")
891
- return
892
- if action == "RemoteStopTransaction":
893
- message = "RemoteStopTransaction error"
894
- if error_code:
895
- message += f": code={str(error_code).strip()}"
896
- if description:
897
- suffix = str(description).strip()
898
- if suffix:
899
- message += f", description={suffix}"
900
- store.add_log(log_key, message, log_type="charger")
901
- return
902
- if action == "Reset":
903
- message = "Reset error"
904
- if error_code:
905
- message += f": code={str(error_code).strip()}"
906
- if description:
907
- suffix = str(description).strip()
908
- if suffix:
909
- message += f", description={suffix}"
910
- store.add_log(log_key, message, log_type="charger")
911
- return
912
- if action != "ChangeAvailability":
913
- return
914
- detail_text = (description or "").strip()
915
- if not detail_text and details:
916
- try:
917
- detail_text = json.dumps(details, sort_keys=True)
918
- except Exception:
919
- detail_text = str(details)
920
- if not detail_text:
921
- detail_text = (error_code or "").strip() or "Error"
922
- requested_type = metadata.get("availability_type")
923
- connector_value = metadata.get("connector_id")
924
- requested_at = metadata.get("requested_at")
925
- await self._update_change_availability_state(
926
- connector_value,
927
- requested_type,
928
- "Rejected",
929
- requested_at,
930
- details=detail_text,
931
- )
932
-
933
- async def _handle_data_transfer(
934
- self, message_id: str, payload: dict | None
935
- ) -> dict[str, object]:
936
- payload = payload if isinstance(payload, dict) else {}
937
- vendor_id = str(payload.get("vendorId") or "").strip()
938
- vendor_message_id = payload.get("messageId")
939
- if vendor_message_id is None:
940
- vendor_message_id_text = ""
941
- elif isinstance(vendor_message_id, str):
942
- vendor_message_id_text = vendor_message_id.strip()
943
- else:
944
- vendor_message_id_text = str(vendor_message_id)
945
- connector_value = self.connector_value
946
-
947
- def _get_or_create_charger():
948
- if self.charger and getattr(self.charger, "pk", None):
949
- return self.charger
950
- if connector_value is None:
951
- charger, _ = Charger.objects.get_or_create(
952
- charger_id=self.charger_id,
953
- connector_id=None,
954
- defaults={"last_path": self.scope.get("path", "")},
955
- )
956
- return charger
957
- charger, _ = Charger.objects.get_or_create(
958
- charger_id=self.charger_id,
959
- connector_id=connector_value,
960
- defaults={"last_path": self.scope.get("path", "")},
961
- )
962
- return charger
963
-
964
- charger_obj = await database_sync_to_async(_get_or_create_charger)()
965
- message = await database_sync_to_async(DataTransferMessage.objects.create)(
966
- charger=charger_obj,
967
- connector_id=connector_value,
968
- direction=DataTransferMessage.DIRECTION_CP_TO_CSMS,
969
- ocpp_message_id=message_id,
970
- vendor_id=vendor_id,
971
- message_id=vendor_message_id_text,
972
- payload=payload or {},
973
- status="Pending",
974
- )
975
-
976
- status = "Rejected" if not vendor_id else "UnknownVendorId"
977
- response_data = None
978
- error_code = ""
979
- error_description = ""
980
- error_details = None
981
-
982
- handler = self._resolve_data_transfer_handler(vendor_id) if vendor_id else None
983
- if handler:
984
- try:
985
- result = handler(message, payload)
986
- if inspect.isawaitable(result):
987
- result = await result
988
- except Exception as exc: # pragma: no cover - defensive guard
989
- status = "Rejected"
990
- error_code = "InternalError"
991
- error_description = str(exc)
992
- else:
993
- if isinstance(result, tuple):
994
- status = str(result[0]) if result else status
995
- if len(result) > 1:
996
- response_data = result[1]
997
- elif isinstance(result, dict):
998
- status = str(result.get("status", status))
999
- if "data" in result:
1000
- response_data = result["data"]
1001
- elif isinstance(result, str):
1002
- status = result
1003
- final_status = status or "Rejected"
1004
-
1005
- def _finalise():
1006
- DataTransferMessage.objects.filter(pk=message.pk).update(
1007
- status=final_status,
1008
- response_data=response_data,
1009
- error_code=error_code,
1010
- error_description=error_description,
1011
- error_details=error_details,
1012
- responded_at=timezone.now(),
1013
- )
1014
-
1015
- await database_sync_to_async(_finalise)()
1016
-
1017
- reply_payload: dict[str, object] = {"status": final_status}
1018
- if response_data is not None:
1019
- reply_payload["data"] = response_data
1020
- return reply_payload
1021
-
1022
- def _resolve_data_transfer_handler(self, vendor_id: str):
1023
- if not vendor_id:
1024
- return None
1025
- candidate = f"handle_data_transfer_{vendor_id.lower()}"
1026
- return getattr(self, candidate, None)
1027
-
1028
- async def _update_change_availability_state(
1029
- self,
1030
- connector_value: int | None,
1031
- requested_type: str | None,
1032
- status: str,
1033
- requested_at,
1034
- *,
1035
- details: str = "",
1036
- ) -> None:
1037
- status_value = status or ""
1038
- now = timezone.now()
1039
-
1040
- def _apply():
1041
- filters: dict[str, object] = {"charger_id": self.charger_id}
1042
- if connector_value is None:
1043
- filters["connector_id__isnull"] = True
1044
- else:
1045
- filters["connector_id"] = connector_value
1046
- targets = list(Charger.objects.filter(**filters))
1047
- if not targets:
1048
- return
1049
- for target in targets:
1050
- updates: dict[str, object] = {
1051
- "availability_request_status": status_value,
1052
- "availability_request_status_at": now,
1053
- "availability_request_details": details,
1054
- }
1055
- if requested_type:
1056
- updates["availability_requested_state"] = requested_type
1057
- if requested_at:
1058
- updates["availability_requested_at"] = requested_at
1059
- elif requested_type:
1060
- updates["availability_requested_at"] = now
1061
- if status_value == "Accepted" and requested_type:
1062
- updates["availability_state"] = requested_type
1063
- updates["availability_state_updated_at"] = now
1064
- Charger.objects.filter(pk=target.pk).update(**updates)
1065
- for field, value in updates.items():
1066
- setattr(target, field, value)
1067
- if self.charger and self.charger.pk == target.pk:
1068
- for field, value in updates.items():
1069
- setattr(self.charger, field, value)
1070
- if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
1071
- for field, value in updates.items():
1072
- setattr(self.aggregate_charger, field, value)
1073
-
1074
- await database_sync_to_async(_apply)()
1075
-
1076
- async def _update_availability_state(
1077
- self,
1078
- state: str,
1079
- timestamp: datetime,
1080
- connector_value: int | None,
1081
- ) -> None:
1082
- def _apply():
1083
- filters: dict[str, object] = {"charger_id": self.charger_id}
1084
- if connector_value is None:
1085
- filters["connector_id__isnull"] = True
1086
- else:
1087
- filters["connector_id"] = connector_value
1088
- updates = {
1089
- "availability_state": state,
1090
- "availability_state_updated_at": timestamp,
1091
- }
1092
- targets = list(Charger.objects.filter(**filters))
1093
- if not targets:
1094
- return
1095
- Charger.objects.filter(pk__in=[target.pk for target in targets]).update(
1096
- **updates
1097
- )
1098
- for target in targets:
1099
- for field, value in updates.items():
1100
- setattr(target, field, value)
1101
- if self.charger and self.charger.pk == target.pk:
1102
- for field, value in updates.items():
1103
- setattr(self.charger, field, value)
1104
- if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
1105
- for field, value in updates.items():
1106
- setattr(self.aggregate_charger, field, value)
1107
-
1108
- await database_sync_to_async(_apply)()
1109
-
1110
- async def disconnect(self, close_code):
1111
- store.release_ip_connection(getattr(self, "client_ip", None), self)
1112
- tx_obj = None
1113
- if self.charger_id:
1114
- tx_obj = store.get_transaction(self.charger_id, self.connector_value)
1115
- if tx_obj:
1116
- await self._update_consumption_message(tx_obj.pk)
1117
- await self._cancel_consumption_message()
1118
- store.connections.pop(self.store_key, None)
1119
- pending_key = store.pending_key(self.charger_id)
1120
- if self.store_key != pending_key:
1121
- store.connections.pop(pending_key, None)
1122
- store.end_session_log(self.store_key)
1123
- store.stop_session_lock()
1124
- store.clear_pending_calls(self.charger_id)
1125
- store.add_log(self.store_key, f"Closed (code={close_code})", log_type="charger")
1126
-
1127
- async def receive(self, text_data=None, bytes_data=None):
1128
- raw = text_data
1129
- if raw is None and bytes_data is not None:
1130
- raw = base64.b64encode(bytes_data).decode("ascii")
1131
- if raw is None:
1132
- return
1133
- store.add_log(self.store_key, raw, log_type="charger")
1134
- store.add_session_message(self.store_key, raw)
1135
- try:
1136
- msg = json.loads(raw)
1137
- except json.JSONDecodeError:
1138
- return
1139
- if not isinstance(msg, list) or not msg:
1140
- return
1141
- message_type = msg[0]
1142
- if message_type == 2:
1143
- msg_id, action = msg[1], msg[2]
1144
- payload = msg[3] if len(msg) > 3 else {}
1145
- reply_payload = {}
1146
- connector_hint = None
1147
- if isinstance(payload, dict):
1148
- connector_hint = payload.get("connectorId")
1149
- follow_up = store.consume_triggered_followup(
1150
- self.charger_id, action, connector_hint
1151
- )
1152
- if follow_up:
1153
- follow_up_log_key = follow_up.get("log_key") or self.store_key
1154
- target_label = follow_up.get("target") or action
1155
- connector_slug_value = follow_up.get("connector")
1156
- suffix = ""
1157
- if (
1158
- connector_slug_value
1159
- and connector_slug_value != store.AGGREGATE_SLUG
1160
- ):
1161
- suffix = f" (connector {connector_slug_value})"
1162
- store.add_log(
1163
- follow_up_log_key,
1164
- f"TriggerMessage follow-up received: {target_label}{suffix}",
1165
- log_type="charger",
1166
- )
1167
- await self._assign_connector(payload.get("connectorId"))
1168
- if action == "BootNotification":
1169
- reply_payload = {
1170
- "currentTime": datetime.utcnow().isoformat() + "Z",
1171
- "interval": 300,
1172
- "status": "Accepted",
1173
- }
1174
- elif action == "DataTransfer":
1175
- reply_payload = await self._handle_data_transfer(msg_id, payload)
1176
- elif action == "Heartbeat":
1177
- reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1178
- now = timezone.now()
1179
- self.charger.last_heartbeat = now
1180
- await database_sync_to_async(
1181
- Charger.objects.filter(pk=self.charger.pk).update
1182
- )(last_heartbeat=now)
1183
- elif action == "StatusNotification":
1184
- await self._assign_connector(payload.get("connectorId"))
1185
- status = (payload.get("status") or "").strip()
1186
- error_code = (payload.get("errorCode") or "").strip()
1187
- vendor_info = {
1188
- key: value
1189
- for key, value in (
1190
- ("info", payload.get("info")),
1191
- ("vendorId", payload.get("vendorId")),
1192
- )
1193
- if value
1194
- }
1195
- vendor_value = vendor_info or None
1196
- timestamp_raw = payload.get("timestamp")
1197
- status_timestamp = (
1198
- parse_datetime(timestamp_raw) if timestamp_raw else None
1199
- )
1200
- if status_timestamp is None:
1201
- status_timestamp = timezone.now()
1202
- elif timezone.is_naive(status_timestamp):
1203
- status_timestamp = timezone.make_aware(status_timestamp)
1204
- update_kwargs = {
1205
- "last_status": status,
1206
- "last_error_code": error_code,
1207
- "last_status_vendor_info": vendor_value,
1208
- "last_status_timestamp": status_timestamp,
1209
- }
1210
-
1211
- def _update_instance(instance: Charger | None) -> None:
1212
- if not instance:
1213
- return
1214
- instance.last_status = status
1215
- instance.last_error_code = error_code
1216
- instance.last_status_vendor_info = vendor_value
1217
- instance.last_status_timestamp = status_timestamp
1218
-
1219
- await database_sync_to_async(
1220
- Charger.objects.filter(
1221
- charger_id=self.charger_id, connector_id=None
1222
- ).update
1223
- )(**update_kwargs)
1224
- connector_value = self.connector_value
1225
- if connector_value is not None:
1226
- await database_sync_to_async(
1227
- Charger.objects.filter(
1228
- charger_id=self.charger_id,
1229
- connector_id=connector_value,
1230
- ).update
1231
- )(**update_kwargs)
1232
- _update_instance(self.aggregate_charger)
1233
- _update_instance(self.charger)
1234
- if connector_value is not None and status.lower() == "available":
1235
- tx_obj = store.transactions.pop(self.store_key, None)
1236
- if tx_obj:
1237
- await self._cancel_consumption_message()
1238
- store.end_session_log(self.store_key)
1239
- store.stop_session_lock()
1240
- store.add_log(
1241
- self.store_key,
1242
- f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
1243
- log_type="charger",
1244
- )
1245
- availability_state = Charger.availability_state_from_status(status)
1246
- if availability_state:
1247
- await self._update_availability_state(
1248
- availability_state, status_timestamp, self.connector_value
1249
- )
1250
- reply_payload = {}
1251
- elif action == "Authorize":
1252
- id_tag = payload.get("idTag")
1253
- account = await self._get_account(id_tag)
1254
- if self.charger.require_rfid:
1255
- status = (
1256
- "Accepted"
1257
- if account
1258
- and await database_sync_to_async(account.can_authorize)()
1259
- else "Invalid"
1260
- )
1261
- else:
1262
- await self._ensure_rfid_seen(id_tag)
1263
- status = "Accepted"
1264
- reply_payload = {"idTagInfo": {"status": status}}
1265
- elif action == "MeterValues":
1266
- await self._store_meter_values(payload, text_data)
1267
- self.charger.last_meter_values = payload
1268
- await database_sync_to_async(
1269
- Charger.objects.filter(pk=self.charger.pk).update
1270
- )(last_meter_values=payload)
1271
- reply_payload = {}
1272
- elif action == "DiagnosticsStatusNotification":
1273
- status_value = payload.get("status")
1274
- location_value = (
1275
- payload.get("uploadLocation")
1276
- or payload.get("location")
1277
- or payload.get("uri")
1278
- )
1279
- timestamp_value = payload.get("timestamp")
1280
- diagnostics_timestamp = None
1281
- if timestamp_value:
1282
- diagnostics_timestamp = parse_datetime(timestamp_value)
1283
- if diagnostics_timestamp and timezone.is_naive(
1284
- diagnostics_timestamp
1285
- ):
1286
- diagnostics_timestamp = timezone.make_aware(
1287
- diagnostics_timestamp, timezone=timezone.utc
1288
- )
1289
-
1290
- updates = {
1291
- "diagnostics_status": status_value or None,
1292
- "diagnostics_timestamp": diagnostics_timestamp,
1293
- "diagnostics_location": location_value or None,
1294
- }
1295
-
1296
- def _persist_diagnostics():
1297
- targets: list[Charger] = []
1298
- if self.charger:
1299
- targets.append(self.charger)
1300
- aggregate = self.aggregate_charger
1301
- if (
1302
- aggregate
1303
- and not any(
1304
- target.pk == aggregate.pk for target in targets if target.pk
1305
- )
1306
- ):
1307
- targets.append(aggregate)
1308
- for target in targets:
1309
- for field, value in updates.items():
1310
- setattr(target, field, value)
1311
- if target.pk:
1312
- Charger.objects.filter(pk=target.pk).update(**updates)
1313
-
1314
- await database_sync_to_async(_persist_diagnostics)()
1315
-
1316
- status_label = updates["diagnostics_status"] or "unknown"
1317
- log_message = "DiagnosticsStatusNotification: status=%s" % (
1318
- status_label,
1319
- )
1320
- if updates["diagnostics_timestamp"]:
1321
- log_message += ", timestamp=%s" % (
1322
- updates["diagnostics_timestamp"].isoformat()
1323
- )
1324
- if updates["diagnostics_location"]:
1325
- log_message += ", location=%s" % updates["diagnostics_location"]
1326
- store.add_log(self.store_key, log_message, log_type="charger")
1327
- if self.aggregate_charger and self.aggregate_charger.connector_id is None:
1328
- aggregate_key = store.identity_key(self.charger_id, None)
1329
- if aggregate_key != self.store_key:
1330
- store.add_log(aggregate_key, log_message, log_type="charger")
1331
- reply_payload = {}
1332
- elif action == "StartTransaction":
1333
- id_tag = payload.get("idTag")
1334
- account = await self._get_account(id_tag)
1335
- if id_tag:
1336
- if self.charger.require_rfid:
1337
- await database_sync_to_async(CoreRFID.objects.get_or_create)(
1338
- rfid=id_tag.upper()
1339
- )
1340
- else:
1341
- await self._ensure_rfid_seen(id_tag)
1342
- await self._assign_connector(payload.get("connectorId"))
1343
- if self.charger.require_rfid:
1344
- authorized = (
1345
- account is not None
1346
- and await database_sync_to_async(account.can_authorize)()
1347
- )
1348
- else:
1349
- authorized = True
1350
- if authorized:
1351
- start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1352
- received_start = timezone.now()
1353
- tx_obj = await database_sync_to_async(Transaction.objects.create)(
1354
- charger=self.charger,
1355
- account=account,
1356
- rfid=(id_tag or ""),
1357
- vin=(payload.get("vin") or ""),
1358
- connector_id=payload.get("connectorId"),
1359
- meter_start=payload.get("meterStart"),
1360
- start_time=start_timestamp or received_start,
1361
- received_start_time=received_start,
1362
- )
1363
- store.transactions[self.store_key] = tx_obj
1364
- store.start_session_log(self.store_key, tx_obj.pk)
1365
- store.start_session_lock()
1366
- store.add_session_message(self.store_key, text_data)
1367
- await self._start_consumption_updates(tx_obj)
1368
- reply_payload = {
1369
- "transactionId": tx_obj.pk,
1370
- "idTagInfo": {"status": "Accepted"},
1371
- }
1372
- else:
1373
- reply_payload = {"idTagInfo": {"status": "Invalid"}}
1374
- elif action == "StopTransaction":
1375
- tx_id = payload.get("transactionId")
1376
- tx_obj = store.transactions.pop(self.store_key, None)
1377
- if not tx_obj and tx_id is not None:
1378
- tx_obj = await database_sync_to_async(
1379
- Transaction.objects.filter(pk=tx_id, charger=self.charger).first
1380
- )()
1381
- if not tx_obj and tx_id is not None:
1382
- received_start = timezone.now()
1383
- tx_obj = await database_sync_to_async(Transaction.objects.create)(
1384
- pk=tx_id,
1385
- charger=self.charger,
1386
- start_time=received_start,
1387
- received_start_time=received_start,
1388
- meter_start=payload.get("meterStart")
1389
- or payload.get("meterStop"),
1390
- vin=(payload.get("vin") or ""),
1391
- )
1392
- if tx_obj:
1393
- stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1394
- received_stop = timezone.now()
1395
- tx_obj.meter_stop = payload.get("meterStop")
1396
- tx_obj.stop_time = stop_timestamp or received_stop
1397
- tx_obj.received_stop_time = received_stop
1398
- await database_sync_to_async(tx_obj.save)()
1399
- await self._update_consumption_message(tx_obj.pk)
1400
- await self._cancel_consumption_message()
1401
- reply_payload = {"idTagInfo": {"status": "Accepted"}}
1402
- store.end_session_log(self.store_key)
1403
- store.stop_session_lock()
1404
- elif action == "FirmwareStatusNotification":
1405
- status_raw = payload.get("status")
1406
- status = str(status_raw or "").strip()
1407
- info_value = payload.get("statusInfo")
1408
- if not isinstance(info_value, str):
1409
- info_value = payload.get("info")
1410
- status_info = str(info_value or "").strip()
1411
- timestamp_raw = payload.get("timestamp")
1412
- timestamp_value = None
1413
- if timestamp_raw:
1414
- timestamp_value = parse_datetime(str(timestamp_raw))
1415
- if timestamp_value and timezone.is_naive(timestamp_value):
1416
- timestamp_value = timezone.make_aware(
1417
- timestamp_value, timezone.get_current_timezone()
1418
- )
1419
- if timestamp_value is None:
1420
- timestamp_value = timezone.now()
1421
- await self._update_firmware_state(
1422
- status, status_info, timestamp_value
1423
- )
1424
- store.add_log(
1425
- self.store_key,
1426
- "FirmwareStatusNotification: "
1427
- + json.dumps(payload, separators=(",", ":")),
1428
- log_type="charger",
1429
- )
1430
- if (
1431
- self.aggregate_charger
1432
- and self.aggregate_charger.connector_id is None
1433
- ):
1434
- aggregate_key = store.identity_key(
1435
- self.charger_id, self.aggregate_charger.connector_id
1436
- )
1437
- if aggregate_key != self.store_key:
1438
- store.add_log(
1439
- aggregate_key,
1440
- "FirmwareStatusNotification: "
1441
- + json.dumps(payload, separators=(",", ":")),
1442
- log_type="charger",
1443
- )
1444
- reply_payload = {}
1445
- response = [3, msg_id, reply_payload]
1446
- await self.send(json.dumps(response))
1447
- store.add_log(
1448
- self.store_key, f"< {json.dumps(response)}", log_type="charger"
1449
- )
1450
- elif message_type == 3:
1451
- msg_id = msg[1] if len(msg) > 1 else ""
1452
- payload = msg[2] if len(msg) > 2 else {}
1453
- await self._handle_call_result(msg_id, payload)
1454
- elif message_type == 4:
1455
- msg_id = msg[1] if len(msg) > 1 else ""
1456
- error_code = msg[2] if len(msg) > 2 else ""
1457
- description = msg[3] if len(msg) > 3 else ""
1458
- details = msg[4] if len(msg) > 4 else {}
1459
- await self._handle_call_error(msg_id, error_code, description, details)
1
+ import base64
2
+ import ipaddress
3
+ import re
4
+ from datetime import datetime
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ from urllib.parse import parse_qs
9
+ from django.utils import timezone
10
+ from core.models import EnergyAccount, Reference, RFID as CoreRFID
11
+ from nodes.models import NetMessage
12
+ from django.core.exceptions import ValidationError
13
+
14
+ from channels.generic.websocket import AsyncWebsocketConsumer
15
+ from channels.db import database_sync_to_async
16
+ from asgiref.sync import sync_to_async
17
+ from config.offline import requires_network
18
+
19
+ from . import store
20
+ from decimal import Decimal
21
+ from django.utils.dateparse import parse_datetime
22
+ from .models import Transaction, Charger, MeterValue, DataTransferMessage
23
+ from .reference_utils import host_is_local_loopback
24
+ from .evcs_discovery import (
25
+ DEFAULT_CONSOLE_PORT,
26
+ HTTPS_PORTS,
27
+ build_console_url,
28
+ prioritise_ports,
29
+ scan_open_ports,
30
+ )
31
+
32
+ FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
33
+
34
+
35
+ # Query parameter keys that may contain the charge point serial. Keys are
36
+ # matched case-insensitively and trimmed before use.
37
+ SERIAL_QUERY_PARAM_NAMES = (
38
+ "cid",
39
+ "chargepointid",
40
+ "charge_point_id",
41
+ "chargeboxid",
42
+ "charge_box_id",
43
+ "chargerid",
44
+ )
45
+
46
+
47
+ def _parse_ip(value: str | None):
48
+ """Return an :mod:`ipaddress` object for the provided value, if valid."""
49
+
50
+ candidate = (value or "").strip()
51
+ if not candidate or candidate.lower() == "unknown":
52
+ return None
53
+ if candidate.lower().startswith("for="):
54
+ candidate = candidate[4:].strip()
55
+ candidate = candidate.strip("'\"")
56
+ if candidate.startswith("["):
57
+ closing = candidate.find("]")
58
+ if closing != -1:
59
+ candidate = candidate[1:closing]
60
+ else:
61
+ candidate = candidate[1:]
62
+ # Remove any comma separated values that may remain.
63
+ if "," in candidate:
64
+ candidate = candidate.split(",", 1)[0].strip()
65
+ try:
66
+ parsed = ipaddress.ip_address(candidate)
67
+ except ValueError:
68
+ host, sep, maybe_port = candidate.rpartition(":")
69
+ if not sep or not maybe_port.isdigit():
70
+ return None
71
+ try:
72
+ parsed = ipaddress.ip_address(host)
73
+ except ValueError:
74
+ return None
75
+ return parsed
76
+
77
+
78
+ def _resolve_client_ip(scope: dict) -> str | None:
79
+ """Return the most useful client IP for the provided ASGI scope."""
80
+
81
+ headers = scope.get("headers") or []
82
+ header_map: dict[str, list[str]] = {}
83
+ for key_bytes, value_bytes in headers:
84
+ try:
85
+ key = key_bytes.decode("latin1").lower()
86
+ except Exception:
87
+ continue
88
+ try:
89
+ value = value_bytes.decode("latin1")
90
+ except Exception:
91
+ value = ""
92
+ header_map.setdefault(key, []).append(value)
93
+
94
+ candidates: list[str] = []
95
+ for raw in header_map.get("x-forwarded-for", []):
96
+ candidates.extend(part.strip() for part in raw.split(","))
97
+ for raw in header_map.get("forwarded", []):
98
+ for segment in raw.split(","):
99
+ match = FORWARDED_PAIR_RE.search(segment)
100
+ if match:
101
+ candidates.append(match.group("value"))
102
+ candidates.extend(header_map.get("x-real-ip", []))
103
+ client = scope.get("client")
104
+ if client:
105
+ candidates.append((client[0] or "").strip())
106
+
107
+ fallback: str | None = None
108
+ for raw in candidates:
109
+ parsed = _parse_ip(raw)
110
+ if not parsed:
111
+ continue
112
+ ip_text = str(parsed)
113
+ if parsed.is_loopback:
114
+ if fallback is None:
115
+ fallback = ip_text
116
+ continue
117
+ return ip_text
118
+ return fallback
119
+
120
+
121
+ def _parse_ocpp_timestamp(value) -> datetime | None:
122
+ """Return an aware :class:`~datetime.datetime` for OCPP timestamps."""
123
+
124
+ if not value:
125
+ return None
126
+ if isinstance(value, datetime):
127
+ timestamp = value
128
+ else:
129
+ timestamp = parse_datetime(str(value))
130
+ if not timestamp:
131
+ return None
132
+ if timezone.is_naive(timestamp):
133
+ timestamp = timezone.make_aware(timestamp, timezone.get_current_timezone())
134
+ return timestamp
135
+
136
+
137
+ class SinkConsumer(AsyncWebsocketConsumer):
138
+ """Accept any message without validation."""
139
+
140
+ @requires_network
141
+ async def connect(self) -> None:
142
+ self.client_ip = _resolve_client_ip(self.scope)
143
+ if not store.register_ip_connection(self.client_ip, self):
144
+ await self.close(code=4003)
145
+ return
146
+ await self.accept()
147
+
148
+ async def disconnect(self, close_code):
149
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
150
+
151
+ async def receive(
152
+ self, text_data: str | None = None, bytes_data: bytes | None = None
153
+ ) -> None:
154
+ if text_data is None:
155
+ return
156
+ try:
157
+ msg = json.loads(text_data)
158
+ if isinstance(msg, list) and msg and msg[0] == 2:
159
+ await self.send(json.dumps([3, msg[1], {}]))
160
+ except Exception:
161
+ pass
162
+
163
+
164
+ class CSMSConsumer(AsyncWebsocketConsumer):
165
+ """Very small subset of OCPP 1.6 CSMS behaviour."""
166
+
167
+ consumption_update_interval = 300
168
+
169
+ def _extract_serial_identifier(self) -> str:
170
+ """Return the charge point serial from the query string or path."""
171
+
172
+ self.serial_source = None
173
+ query_bytes = self.scope.get("query_string") or b""
174
+ self._raw_query_string = query_bytes.decode("utf-8", "ignore") if query_bytes else ""
175
+ if query_bytes:
176
+ try:
177
+ parsed = parse_qs(
178
+ self._raw_query_string,
179
+ keep_blank_values=False,
180
+ )
181
+ except Exception:
182
+ parsed = {}
183
+ if parsed:
184
+ normalized = {
185
+ key.lower(): values for key, values in parsed.items() if values
186
+ }
187
+ for candidate in SERIAL_QUERY_PARAM_NAMES:
188
+ values = normalized.get(candidate)
189
+ if not values:
190
+ continue
191
+ for value in values:
192
+ if not value:
193
+ continue
194
+ trimmed = value.strip()
195
+ if trimmed:
196
+ return trimmed
197
+
198
+ return self.scope["url_route"]["kwargs"].get("cid", "")
199
+
200
+ @requires_network
201
+ async def connect(self):
202
+ raw_serial = self._extract_serial_identifier()
203
+ try:
204
+ self.charger_id = Charger.validate_serial(raw_serial)
205
+ except ValidationError as exc:
206
+ serial = Charger.normalize_serial(raw_serial)
207
+ store_key = store.pending_key(serial)
208
+ message = exc.messages[0] if exc.messages else "Invalid Serial Number"
209
+ details: list[str] = []
210
+ if getattr(self, "serial_source", None):
211
+ details.append(f"serial_source={self.serial_source}")
212
+ if getattr(self, "_raw_query_string", ""):
213
+ details.append(f"query_string={self._raw_query_string!r}")
214
+ if details:
215
+ message = f"{message} ({'; '.join(details)})"
216
+ store.add_log(
217
+ store_key,
218
+ f"Rejected connection: {message}",
219
+ log_type="charger",
220
+ )
221
+ await self.close(code=4003)
222
+ return
223
+ self.connector_value: int | None = None
224
+ self.store_key = store.pending_key(self.charger_id)
225
+ self.aggregate_charger: Charger | None = None
226
+ self._consumption_task: asyncio.Task | None = None
227
+ self._consumption_message_uuid: str | None = None
228
+ subprotocol = None
229
+ offered = self.scope.get("subprotocols", [])
230
+ if "ocpp1.6" in offered:
231
+ subprotocol = "ocpp1.6"
232
+ self.client_ip = _resolve_client_ip(self.scope)
233
+ self._header_reference_created = False
234
+ # Close any pending connection for this charger so reconnections do
235
+ # not leak stale consumers when the connector id has not been
236
+ # negotiated yet.
237
+ existing = store.connections.get(self.store_key)
238
+ if existing is not None:
239
+ store.release_ip_connection(getattr(existing, "client_ip", None), existing)
240
+ await existing.close()
241
+ if not store.register_ip_connection(self.client_ip, self):
242
+ store.add_log(
243
+ self.store_key,
244
+ f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
245
+ log_type="charger",
246
+ )
247
+ await self.close(code=4003)
248
+ return
249
+ await self.accept(subprotocol=subprotocol)
250
+ store.add_log(
251
+ self.store_key,
252
+ f"Connected (subprotocol={subprotocol or 'none'})",
253
+ log_type="charger",
254
+ )
255
+ store.connections[self.store_key] = self
256
+ store.logs["charger"].setdefault(self.store_key, [])
257
+ self.charger, created = await database_sync_to_async(
258
+ Charger.objects.get_or_create
259
+ )(
260
+ charger_id=self.charger_id,
261
+ connector_id=None,
262
+ defaults={"last_path": self.scope.get("path", "")},
263
+ )
264
+ await database_sync_to_async(self.charger.refresh_manager_node)()
265
+ self.aggregate_charger = self.charger
266
+ location_name = await sync_to_async(
267
+ lambda: self.charger.location.name if self.charger.location else ""
268
+ )()
269
+ friendly_name = location_name or self.charger_id
270
+ store.register_log_name(self.store_key, friendly_name, log_type="charger")
271
+ store.register_log_name(self.charger_id, friendly_name, log_type="charger")
272
+ store.register_log_name(
273
+ store.identity_key(self.charger_id, None),
274
+ friendly_name,
275
+ log_type="charger",
276
+ )
277
+
278
+ async def _get_account(self, id_tag: str) -> EnergyAccount | None:
279
+ """Return the energy account for the provided RFID if valid."""
280
+ if not id_tag:
281
+ return None
282
+ return await database_sync_to_async(
283
+ EnergyAccount.objects.filter(
284
+ rfids__rfid=id_tag.upper(), rfids__allowed=True
285
+ ).first
286
+ )()
287
+
288
+ async def _ensure_rfid_seen(self, id_tag: str) -> CoreRFID | None:
289
+ """Ensure an RFID record exists and update its last seen timestamp."""
290
+
291
+ if not id_tag:
292
+ return None
293
+
294
+ normalized = id_tag.upper()
295
+
296
+ def _ensure() -> CoreRFID:
297
+ now = timezone.now()
298
+ tag, _created = CoreRFID.register_scan(normalized)
299
+ updates = []
300
+ if not tag.allowed:
301
+ tag.allowed = True
302
+ updates.append("allowed")
303
+ if tag.last_seen_on != now:
304
+ tag.last_seen_on = now
305
+ updates.append("last_seen_on")
306
+ if updates:
307
+ tag.save(update_fields=updates)
308
+ return tag
309
+
310
+ return await database_sync_to_async(_ensure)()
311
+
312
+ async def _assign_connector(self, connector: int | str | None) -> None:
313
+ """Ensure ``self.charger`` matches the provided connector id."""
314
+ if connector in (None, "", "-"):
315
+ connector_value = None
316
+ else:
317
+ try:
318
+ connector_value = int(connector)
319
+ if connector_value == 0:
320
+ connector_value = None
321
+ except (TypeError, ValueError):
322
+ return
323
+ if connector_value is None:
324
+ if not self._header_reference_created and self.client_ip:
325
+ await database_sync_to_async(self._ensure_console_reference)()
326
+ self._header_reference_created = True
327
+ return
328
+ if (
329
+ self.connector_value == connector_value
330
+ and self.charger.connector_id == connector_value
331
+ ):
332
+ return
333
+ if (
334
+ not self.aggregate_charger
335
+ or self.aggregate_charger.connector_id is not None
336
+ ):
337
+ aggregate, _ = await database_sync_to_async(
338
+ Charger.objects.get_or_create
339
+ )(
340
+ charger_id=self.charger_id,
341
+ connector_id=None,
342
+ defaults={"last_path": self.scope.get("path", "")},
343
+ )
344
+ await database_sync_to_async(aggregate.refresh_manager_node)()
345
+ self.aggregate_charger = aggregate
346
+ existing = await database_sync_to_async(
347
+ Charger.objects.filter(
348
+ charger_id=self.charger_id, connector_id=connector_value
349
+ ).first
350
+ )()
351
+ if existing:
352
+ self.charger = existing
353
+ await database_sync_to_async(self.charger.refresh_manager_node)()
354
+ else:
355
+
356
+ def _create_connector():
357
+ charger, _ = Charger.objects.get_or_create(
358
+ charger_id=self.charger_id,
359
+ connector_id=connector_value,
360
+ defaults={"last_path": self.scope.get("path", "")},
361
+ )
362
+ if self.scope.get("path") and charger.last_path != self.scope.get(
363
+ "path"
364
+ ):
365
+ charger.last_path = self.scope.get("path")
366
+ charger.save(update_fields=["last_path"])
367
+ charger.refresh_manager_node()
368
+ return charger
369
+
370
+ self.charger = await database_sync_to_async(_create_connector)()
371
+ previous_key = self.store_key
372
+ new_key = store.identity_key(self.charger_id, connector_value)
373
+ if previous_key != new_key:
374
+ existing_consumer = store.connections.get(new_key)
375
+ if existing_consumer is not None and existing_consumer is not self:
376
+ await existing_consumer.close()
377
+ store.reassign_identity(previous_key, new_key)
378
+ store.connections[new_key] = self
379
+ store.logs["charger"].setdefault(new_key, [])
380
+ connector_name = await sync_to_async(
381
+ lambda: self.charger.name or self.charger.charger_id
382
+ )()
383
+ store.register_log_name(new_key, connector_name, log_type="charger")
384
+ aggregate_name = ""
385
+ if self.aggregate_charger:
386
+ aggregate_name = await sync_to_async(
387
+ lambda: self.aggregate_charger.name or self.aggregate_charger.charger_id
388
+ )()
389
+ store.register_log_name(
390
+ store.identity_key(self.charger_id, None),
391
+ aggregate_name or self.charger_id,
392
+ log_type="charger",
393
+ )
394
+ self.store_key = new_key
395
+ self.connector_value = connector_value
396
+
397
+ def _ensure_console_reference(self) -> None:
398
+ """Create or update a header reference for the connected charger."""
399
+
400
+ ip = (self.client_ip or "").strip()
401
+ serial = (self.charger_id or "").strip()
402
+ if not ip or not serial:
403
+ return
404
+ if host_is_local_loopback(ip):
405
+ return
406
+ host = ip
407
+ ports = scan_open_ports(host)
408
+ if ports:
409
+ ordered_ports = prioritise_ports(ports)
410
+ else:
411
+ ordered_ports = prioritise_ports([DEFAULT_CONSOLE_PORT])
412
+ port = ordered_ports[0] if ordered_ports else DEFAULT_CONSOLE_PORT
413
+ secure = port in HTTPS_PORTS
414
+ url = build_console_url(host, port, secure)
415
+ alt_text = f"{serial} Console"
416
+ reference = Reference.objects.filter(alt_text=alt_text).order_by("id").first()
417
+ if reference is None:
418
+ reference = Reference.objects.create(
419
+ alt_text=alt_text,
420
+ value=url,
421
+ show_in_header=True,
422
+ method="link",
423
+ )
424
+ updated_fields: list[str] = []
425
+ if reference.value != url:
426
+ reference.value = url
427
+ updated_fields.append("value")
428
+ if reference.method != "link":
429
+ reference.method = "link"
430
+ updated_fields.append("method")
431
+ if not reference.show_in_header:
432
+ reference.show_in_header = True
433
+ updated_fields.append("show_in_header")
434
+ if updated_fields:
435
+ reference.save(update_fields=updated_fields)
436
+
437
+ async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
438
+ """Parse a MeterValues payload into MeterValue rows."""
439
+ connector_raw = payload.get("connectorId")
440
+ connector_value = None
441
+ if connector_raw is not None:
442
+ try:
443
+ connector_value = int(connector_raw)
444
+ except (TypeError, ValueError):
445
+ connector_value = None
446
+ await self._assign_connector(connector_value)
447
+ tx_id = payload.get("transactionId")
448
+ tx_obj = None
449
+ if tx_id is not None:
450
+ tx_obj = store.transactions.get(self.store_key)
451
+ if not tx_obj or tx_obj.pk != int(tx_id):
452
+ tx_obj = await database_sync_to_async(
453
+ Transaction.objects.filter(pk=tx_id, charger=self.charger).first
454
+ )()
455
+ if tx_obj is None:
456
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
457
+ pk=tx_id, charger=self.charger, start_time=timezone.now()
458
+ )
459
+ store.start_session_log(self.store_key, tx_obj.pk)
460
+ store.add_session_message(self.store_key, raw_message)
461
+ store.transactions[self.store_key] = tx_obj
462
+ else:
463
+ tx_obj = store.transactions.get(self.store_key)
464
+
465
+ readings = []
466
+ updated_fields: set[str] = set()
467
+ temperature = None
468
+ temp_unit = ""
469
+ for mv in payload.get("meterValue", []):
470
+ ts = parse_datetime(mv.get("timestamp"))
471
+ values: dict[str, Decimal] = {}
472
+ context = ""
473
+ for sv in mv.get("sampledValue", []):
474
+ try:
475
+ val = Decimal(str(sv.get("value")))
476
+ except Exception:
477
+ continue
478
+ context = sv.get("context", context or "")
479
+ measurand = sv.get("measurand", "")
480
+ unit = sv.get("unit", "")
481
+ field = None
482
+ if measurand in ("", "Energy.Active.Import.Register"):
483
+ field = "energy"
484
+ if unit == "Wh":
485
+ val = val / Decimal("1000")
486
+ elif measurand == "Voltage":
487
+ field = "voltage"
488
+ elif measurand == "Current.Import":
489
+ field = "current_import"
490
+ elif measurand == "Current.Offered":
491
+ field = "current_offered"
492
+ elif measurand == "Temperature":
493
+ field = "temperature"
494
+ temperature = val
495
+ temp_unit = unit
496
+ elif measurand == "SoC":
497
+ field = "soc"
498
+ if field:
499
+ if tx_obj and context in ("Transaction.Begin", "Transaction.End"):
500
+ suffix = "start" if context == "Transaction.Begin" else "stop"
501
+ if field == "energy":
502
+ mult = 1000 if unit in ("kW", "kWh") else 1
503
+ setattr(tx_obj, f"meter_{suffix}", int(val * mult))
504
+ updated_fields.add(f"meter_{suffix}")
505
+ else:
506
+ setattr(tx_obj, f"{field}_{suffix}", val)
507
+ updated_fields.add(f"{field}_{suffix}")
508
+ else:
509
+ values[field] = val
510
+ if tx_obj and field == "energy" and tx_obj.meter_start is None:
511
+ mult = 1000 if unit in ("kW", "kWh") else 1
512
+ try:
513
+ tx_obj.meter_start = int(val * mult)
514
+ except (TypeError, ValueError):
515
+ pass
516
+ else:
517
+ updated_fields.add("meter_start")
518
+ if values and context not in ("Transaction.Begin", "Transaction.End"):
519
+ readings.append(
520
+ MeterValue(
521
+ charger=self.charger,
522
+ connector_id=connector_value,
523
+ transaction=tx_obj,
524
+ timestamp=ts,
525
+ context=context,
526
+ **values,
527
+ )
528
+ )
529
+ if readings:
530
+ await database_sync_to_async(MeterValue.objects.bulk_create)(readings)
531
+ if tx_obj and updated_fields:
532
+ await database_sync_to_async(tx_obj.save)(
533
+ update_fields=list(updated_fields)
534
+ )
535
+ if connector_value is not None and not self.charger.connector_id:
536
+ self.charger.connector_id = connector_value
537
+ await database_sync_to_async(self.charger.save)(
538
+ update_fields=["connector_id"]
539
+ )
540
+ if temperature is not None:
541
+ self.charger.temperature = temperature
542
+ self.charger.temperature_unit = temp_unit
543
+ await database_sync_to_async(self.charger.save)(
544
+ update_fields=["temperature", "temperature_unit"]
545
+ )
546
+
547
+ async def _update_firmware_state(
548
+ self, status: str, status_info: str, timestamp: datetime | None
549
+ ) -> None:
550
+ """Persist firmware status fields for the active charger identities."""
551
+
552
+ targets: list[Charger] = []
553
+ seen_ids: set[int] = set()
554
+ for charger in (self.charger, self.aggregate_charger):
555
+ if not charger or charger.pk is None:
556
+ continue
557
+ if charger.pk in seen_ids:
558
+ continue
559
+ targets.append(charger)
560
+ seen_ids.add(charger.pk)
561
+
562
+ if not targets:
563
+ return
564
+
565
+ def _persist(ids: list[int]) -> None:
566
+ Charger.objects.filter(pk__in=ids).update(
567
+ firmware_status=status,
568
+ firmware_status_info=status_info,
569
+ firmware_timestamp=timestamp,
570
+ )
571
+
572
+ await database_sync_to_async(_persist)([target.pk for target in targets])
573
+ for target in targets:
574
+ target.firmware_status = status
575
+ target.firmware_status_info = status_info
576
+ target.firmware_timestamp = timestamp
577
+
578
+ async def _cancel_consumption_message(self) -> None:
579
+ """Stop any scheduled consumption message updates."""
580
+
581
+ task = self._consumption_task
582
+ self._consumption_task = None
583
+ if task:
584
+ task.cancel()
585
+ try:
586
+ await task
587
+ except asyncio.CancelledError:
588
+ pass
589
+ self._consumption_message_uuid = None
590
+
591
+ async def _update_consumption_message(self, tx_id: int) -> str | None:
592
+ """Create or update the Net Message for an active transaction."""
593
+
594
+ existing_uuid = self._consumption_message_uuid
595
+
596
+ def _persist() -> str | None:
597
+ tx = (
598
+ Transaction.objects.select_related("charger")
599
+ .filter(pk=tx_id)
600
+ .first()
601
+ )
602
+ if not tx:
603
+ return None
604
+ charger = tx.charger or self.charger
605
+ serial = ""
606
+ if charger and charger.charger_id:
607
+ serial = charger.charger_id
608
+ elif self.charger_id:
609
+ serial = self.charger_id
610
+ serial = serial[:64]
611
+ if not serial:
612
+ return None
613
+ now_local = timezone.localtime(timezone.now())
614
+ body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
615
+ if existing_uuid:
616
+ msg = NetMessage.objects.filter(uuid=existing_uuid).first()
617
+ if msg:
618
+ msg.subject = serial
619
+ msg.body = body_value
620
+ msg.save(update_fields=["subject", "body"])
621
+ msg.propagate()
622
+ return str(msg.uuid)
623
+ msg = NetMessage.broadcast(subject=serial, body=body_value)
624
+ return str(msg.uuid)
625
+
626
+ try:
627
+ result = await database_sync_to_async(_persist)()
628
+ except Exception as exc: # pragma: no cover - unexpected errors
629
+ store.add_log(
630
+ self.store_key,
631
+ f"Failed to broadcast consumption message: {exc}",
632
+ log_type="charger",
633
+ )
634
+ return None
635
+ if result is None:
636
+ store.add_log(
637
+ self.store_key,
638
+ "Unable to broadcast consumption message: missing data",
639
+ log_type="charger",
640
+ )
641
+ return None
642
+ self._consumption_message_uuid = result
643
+ return result
644
+
645
+ async def _consumption_message_loop(self, tx_id: int) -> None:
646
+ """Periodically refresh the consumption Net Message."""
647
+
648
+ try:
649
+ while True:
650
+ await asyncio.sleep(self.consumption_update_interval)
651
+ updated = await self._update_consumption_message(tx_id)
652
+ if not updated:
653
+ break
654
+ except asyncio.CancelledError:
655
+ pass
656
+ except Exception as exc: # pragma: no cover - unexpected errors
657
+ store.add_log(
658
+ self.store_key,
659
+ f"Failed to refresh consumption message: {exc}",
660
+ log_type="charger",
661
+ )
662
+
663
+ async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
664
+ """Send the initial consumption message and schedule updates."""
665
+
666
+ await self._cancel_consumption_message()
667
+ initial = await self._update_consumption_message(tx_obj.pk)
668
+ if not initial:
669
+ return
670
+ task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
671
+ task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
672
+ self._consumption_task = task
673
+
674
+ async def _handle_call_result(self, message_id: str, payload: dict | None) -> None:
675
+ metadata = store.pop_pending_call(message_id)
676
+ if not metadata:
677
+ return
678
+ if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
679
+ return
680
+ action = metadata.get("action")
681
+ log_key = metadata.get("log_key") or self.store_key
682
+ payload_data = payload if isinstance(payload, dict) else {}
683
+ if action == "DataTransfer":
684
+ message_pk = metadata.get("message_pk")
685
+ if not message_pk:
686
+ store.record_pending_call_result(
687
+ message_id,
688
+ metadata=metadata,
689
+ payload=payload_data,
690
+ )
691
+ return
692
+
693
+ def _apply():
694
+ message = DataTransferMessage.objects.filter(pk=message_pk).first()
695
+ if not message:
696
+ return
697
+ status_value = str((payload or {}).get("status") or "").strip()
698
+ message.status = status_value
699
+ message.response_data = (payload or {}).get("data")
700
+ message.error_code = ""
701
+ message.error_description = ""
702
+ message.error_details = None
703
+ message.responded_at = timezone.now()
704
+ message.save(
705
+ update_fields=[
706
+ "status",
707
+ "response_data",
708
+ "error_code",
709
+ "error_description",
710
+ "error_details",
711
+ "responded_at",
712
+ "updated_at",
713
+ ]
714
+ )
715
+
716
+ await database_sync_to_async(_apply)()
717
+ store.record_pending_call_result(
718
+ message_id,
719
+ metadata=metadata,
720
+ payload=payload_data,
721
+ )
722
+ return
723
+ if action == "GetConfiguration":
724
+ try:
725
+ payload_text = json.dumps(
726
+ payload_data, sort_keys=True, ensure_ascii=False
727
+ )
728
+ except TypeError:
729
+ payload_text = str(payload_data)
730
+ store.add_log(
731
+ log_key,
732
+ f"GetConfiguration result: {payload_text}",
733
+ log_type="charger",
734
+ )
735
+ store.record_pending_call_result(
736
+ message_id,
737
+ metadata=metadata,
738
+ payload=payload_data,
739
+ )
740
+ return
741
+ if action == "TriggerMessage":
742
+ status_value = str(payload_data.get("status") or "").strip()
743
+ target = metadata.get("trigger_target") or metadata.get("follow_up_action")
744
+ connector_value = metadata.get("trigger_connector")
745
+ message = "TriggerMessage result"
746
+ if target:
747
+ message = f"TriggerMessage {target} result"
748
+ if status_value:
749
+ message += f": status={status_value}"
750
+ if connector_value:
751
+ message += f", connector={connector_value}"
752
+ store.add_log(log_key, message, log_type="charger")
753
+ if status_value == "Accepted" and target:
754
+ store.register_triggered_followup(
755
+ self.charger_id,
756
+ str(target),
757
+ connector=connector_value,
758
+ log_key=log_key,
759
+ target=str(target),
760
+ )
761
+ store.record_pending_call_result(
762
+ message_id,
763
+ metadata=metadata,
764
+ payload=payload_data,
765
+ )
766
+ return
767
+ if action == "RemoteStartTransaction":
768
+ status_value = str(payload_data.get("status") or "").strip()
769
+ message = "RemoteStartTransaction result"
770
+ if status_value:
771
+ message += f": status={status_value}"
772
+ store.add_log(log_key, message, log_type="charger")
773
+ store.record_pending_call_result(
774
+ message_id,
775
+ metadata=metadata,
776
+ payload=payload_data,
777
+ )
778
+ return
779
+ if action == "RemoteStopTransaction":
780
+ status_value = str(payload_data.get("status") or "").strip()
781
+ message = "RemoteStopTransaction result"
782
+ if status_value:
783
+ message += f": status={status_value}"
784
+ store.add_log(log_key, message, log_type="charger")
785
+ store.record_pending_call_result(
786
+ message_id,
787
+ metadata=metadata,
788
+ payload=payload_data,
789
+ )
790
+ return
791
+ if action == "Reset":
792
+ status_value = str(payload_data.get("status") or "").strip()
793
+ message = "Reset result"
794
+ if status_value:
795
+ message += f": status={status_value}"
796
+ store.add_log(log_key, message, log_type="charger")
797
+ store.record_pending_call_result(
798
+ message_id,
799
+ metadata=metadata,
800
+ payload=payload_data,
801
+ )
802
+ return
803
+ if action != "ChangeAvailability":
804
+ store.record_pending_call_result(
805
+ message_id,
806
+ metadata=metadata,
807
+ payload=payload_data,
808
+ )
809
+ return
810
+ status = str((payload or {}).get("status") or "").strip()
811
+ requested_type = metadata.get("availability_type")
812
+ connector_value = metadata.get("connector_id")
813
+ requested_at = metadata.get("requested_at")
814
+ await self._update_change_availability_state(
815
+ connector_value,
816
+ requested_type,
817
+ status,
818
+ requested_at,
819
+ details="",
820
+ )
821
+ store.record_pending_call_result(
822
+ message_id,
823
+ metadata=metadata,
824
+ payload=payload_data,
825
+ )
826
+
827
+ async def _handle_call_error(
828
+ self,
829
+ message_id: str,
830
+ error_code: str | None,
831
+ description: str | None,
832
+ details: dict | None,
833
+ ) -> None:
834
+ metadata = store.pop_pending_call(message_id)
835
+ if not metadata:
836
+ return
837
+ if metadata.get("charger_id") and metadata.get("charger_id") != self.charger_id:
838
+ return
839
+ action = metadata.get("action")
840
+ log_key = metadata.get("log_key") or self.store_key
841
+ if action == "DataTransfer":
842
+ message_pk = metadata.get("message_pk")
843
+ if not message_pk:
844
+ store.record_pending_call_result(
845
+ message_id,
846
+ metadata=metadata,
847
+ success=False,
848
+ error_code=error_code,
849
+ error_description=description,
850
+ error_details=details,
851
+ )
852
+ return
853
+
854
+ def _apply():
855
+ message = DataTransferMessage.objects.filter(pk=message_pk).first()
856
+ if not message:
857
+ return
858
+ status_value = (error_code or "Error").strip() or "Error"
859
+ message.status = status_value
860
+ message.response_data = None
861
+ message.error_code = (error_code or "").strip()
862
+ message.error_description = (description or "").strip()
863
+ message.error_details = details
864
+ message.responded_at = timezone.now()
865
+ message.save(
866
+ update_fields=[
867
+ "status",
868
+ "response_data",
869
+ "error_code",
870
+ "error_description",
871
+ "error_details",
872
+ "responded_at",
873
+ "updated_at",
874
+ ]
875
+ )
876
+
877
+ await database_sync_to_async(_apply)()
878
+ store.record_pending_call_result(
879
+ message_id,
880
+ metadata=metadata,
881
+ success=False,
882
+ error_code=error_code,
883
+ error_description=description,
884
+ error_details=details,
885
+ )
886
+ return
887
+ if action == "GetConfiguration":
888
+ parts: list[str] = []
889
+ code_text = (error_code or "").strip()
890
+ if code_text:
891
+ parts.append(f"code={code_text}")
892
+ description_text = (description or "").strip()
893
+ if description_text:
894
+ parts.append(f"description={description_text}")
895
+ if details:
896
+ try:
897
+ details_text = json.dumps(details, sort_keys=True, ensure_ascii=False)
898
+ except TypeError:
899
+ details_text = str(details)
900
+ if details_text:
901
+ parts.append(f"details={details_text}")
902
+ if parts:
903
+ message = "GetConfiguration error: " + ", ".join(parts)
904
+ else:
905
+ message = "GetConfiguration error"
906
+ store.add_log(log_key, message, log_type="charger")
907
+ store.record_pending_call_result(
908
+ message_id,
909
+ metadata=metadata,
910
+ success=False,
911
+ error_code=error_code,
912
+ error_description=description,
913
+ error_details=details,
914
+ )
915
+ return
916
+ if action == "TriggerMessage":
917
+ target = metadata.get("trigger_target") or metadata.get("follow_up_action")
918
+ connector_value = metadata.get("trigger_connector")
919
+ parts: list[str] = []
920
+ if error_code:
921
+ parts.append(f"code={str(error_code).strip()}")
922
+ if description:
923
+ parts.append(f"description={str(description).strip()}")
924
+ if details:
925
+ try:
926
+ parts.append(
927
+ "details="
928
+ + json.dumps(details, sort_keys=True, ensure_ascii=False)
929
+ )
930
+ except TypeError:
931
+ parts.append(f"details={details}")
932
+ label = f"TriggerMessage {target}" if target else "TriggerMessage"
933
+ message = label + " error"
934
+ if parts:
935
+ message += ": " + ", ".join(parts)
936
+ if connector_value:
937
+ message += f", connector={connector_value}"
938
+ store.add_log(log_key, message, log_type="charger")
939
+ store.record_pending_call_result(
940
+ message_id,
941
+ metadata=metadata,
942
+ success=False,
943
+ error_code=error_code,
944
+ error_description=description,
945
+ error_details=details,
946
+ )
947
+ return
948
+ if action == "RemoteStartTransaction":
949
+ message = "RemoteStartTransaction error"
950
+ if error_code:
951
+ message += f": code={str(error_code).strip()}"
952
+ if description:
953
+ suffix = str(description).strip()
954
+ if suffix:
955
+ message += f", description={suffix}"
956
+ store.add_log(log_key, message, log_type="charger")
957
+ store.record_pending_call_result(
958
+ message_id,
959
+ metadata=metadata,
960
+ success=False,
961
+ error_code=error_code,
962
+ error_description=description,
963
+ error_details=details,
964
+ )
965
+ return
966
+ if action == "RemoteStopTransaction":
967
+ message = "RemoteStopTransaction error"
968
+ if error_code:
969
+ message += f": code={str(error_code).strip()}"
970
+ if description:
971
+ suffix = str(description).strip()
972
+ if suffix:
973
+ message += f", description={suffix}"
974
+ store.add_log(log_key, message, log_type="charger")
975
+ store.record_pending_call_result(
976
+ message_id,
977
+ metadata=metadata,
978
+ success=False,
979
+ error_code=error_code,
980
+ error_description=description,
981
+ error_details=details,
982
+ )
983
+ return
984
+ if action == "Reset":
985
+ message = "Reset error"
986
+ if error_code:
987
+ message += f": code={str(error_code).strip()}"
988
+ if description:
989
+ suffix = str(description).strip()
990
+ if suffix:
991
+ message += f", description={suffix}"
992
+ store.add_log(log_key, message, log_type="charger")
993
+ store.record_pending_call_result(
994
+ message_id,
995
+ metadata=metadata,
996
+ success=False,
997
+ error_code=error_code,
998
+ error_description=description,
999
+ error_details=details,
1000
+ )
1001
+ return
1002
+ if action != "ChangeAvailability":
1003
+ store.record_pending_call_result(
1004
+ message_id,
1005
+ metadata=metadata,
1006
+ success=False,
1007
+ error_code=error_code,
1008
+ error_description=description,
1009
+ error_details=details,
1010
+ )
1011
+ return
1012
+ detail_text = (description or "").strip()
1013
+ if not detail_text and details:
1014
+ try:
1015
+ detail_text = json.dumps(details, sort_keys=True)
1016
+ except Exception:
1017
+ detail_text = str(details)
1018
+ if not detail_text:
1019
+ detail_text = (error_code or "").strip() or "Error"
1020
+ requested_type = metadata.get("availability_type")
1021
+ connector_value = metadata.get("connector_id")
1022
+ requested_at = metadata.get("requested_at")
1023
+ await self._update_change_availability_state(
1024
+ connector_value,
1025
+ requested_type,
1026
+ "Rejected",
1027
+ requested_at,
1028
+ details=detail_text,
1029
+ )
1030
+ store.record_pending_call_result(
1031
+ message_id,
1032
+ metadata=metadata,
1033
+ success=False,
1034
+ error_code=error_code,
1035
+ error_description=description,
1036
+ error_details=details,
1037
+ )
1038
+
1039
+ async def _handle_data_transfer(
1040
+ self, message_id: str, payload: dict | None
1041
+ ) -> dict[str, object]:
1042
+ payload = payload if isinstance(payload, dict) else {}
1043
+ vendor_id = str(payload.get("vendorId") or "").strip()
1044
+ vendor_message_id = payload.get("messageId")
1045
+ if vendor_message_id is None:
1046
+ vendor_message_id_text = ""
1047
+ elif isinstance(vendor_message_id, str):
1048
+ vendor_message_id_text = vendor_message_id.strip()
1049
+ else:
1050
+ vendor_message_id_text = str(vendor_message_id)
1051
+ connector_value = self.connector_value
1052
+
1053
+ def _get_or_create_charger():
1054
+ if self.charger and getattr(self.charger, "pk", None):
1055
+ return self.charger
1056
+ if connector_value is None:
1057
+ charger, _ = Charger.objects.get_or_create(
1058
+ charger_id=self.charger_id,
1059
+ connector_id=None,
1060
+ defaults={"last_path": self.scope.get("path", "")},
1061
+ )
1062
+ return charger
1063
+ charger, _ = Charger.objects.get_or_create(
1064
+ charger_id=self.charger_id,
1065
+ connector_id=connector_value,
1066
+ defaults={"last_path": self.scope.get("path", "")},
1067
+ )
1068
+ return charger
1069
+
1070
+ charger_obj = await database_sync_to_async(_get_or_create_charger)()
1071
+ message = await database_sync_to_async(DataTransferMessage.objects.create)(
1072
+ charger=charger_obj,
1073
+ connector_id=connector_value,
1074
+ direction=DataTransferMessage.DIRECTION_CP_TO_CSMS,
1075
+ ocpp_message_id=message_id,
1076
+ vendor_id=vendor_id,
1077
+ message_id=vendor_message_id_text,
1078
+ payload=payload or {},
1079
+ status="Pending",
1080
+ )
1081
+
1082
+ status = "Rejected" if not vendor_id else "UnknownVendorId"
1083
+ response_data = None
1084
+ error_code = ""
1085
+ error_description = ""
1086
+ error_details = None
1087
+
1088
+ handler = self._resolve_data_transfer_handler(vendor_id) if vendor_id else None
1089
+ if handler:
1090
+ try:
1091
+ result = handler(message, payload)
1092
+ if inspect.isawaitable(result):
1093
+ result = await result
1094
+ except Exception as exc: # pragma: no cover - defensive guard
1095
+ status = "Rejected"
1096
+ error_code = "InternalError"
1097
+ error_description = str(exc)
1098
+ else:
1099
+ if isinstance(result, tuple):
1100
+ status = str(result[0]) if result else status
1101
+ if len(result) > 1:
1102
+ response_data = result[1]
1103
+ elif isinstance(result, dict):
1104
+ status = str(result.get("status", status))
1105
+ if "data" in result:
1106
+ response_data = result["data"]
1107
+ elif isinstance(result, str):
1108
+ status = result
1109
+ final_status = status or "Rejected"
1110
+
1111
+ def _finalise():
1112
+ DataTransferMessage.objects.filter(pk=message.pk).update(
1113
+ status=final_status,
1114
+ response_data=response_data,
1115
+ error_code=error_code,
1116
+ error_description=error_description,
1117
+ error_details=error_details,
1118
+ responded_at=timezone.now(),
1119
+ )
1120
+
1121
+ await database_sync_to_async(_finalise)()
1122
+
1123
+ reply_payload: dict[str, object] = {"status": final_status}
1124
+ if response_data is not None:
1125
+ reply_payload["data"] = response_data
1126
+ return reply_payload
1127
+
1128
+ def _resolve_data_transfer_handler(self, vendor_id: str):
1129
+ if not vendor_id:
1130
+ return None
1131
+ candidate = f"handle_data_transfer_{vendor_id.lower()}"
1132
+ return getattr(self, candidate, None)
1133
+
1134
+ async def _update_change_availability_state(
1135
+ self,
1136
+ connector_value: int | None,
1137
+ requested_type: str | None,
1138
+ status: str,
1139
+ requested_at,
1140
+ *,
1141
+ details: str = "",
1142
+ ) -> None:
1143
+ status_value = status or ""
1144
+ now = timezone.now()
1145
+
1146
+ def _apply():
1147
+ filters: dict[str, object] = {"charger_id": self.charger_id}
1148
+ if connector_value is None:
1149
+ filters["connector_id__isnull"] = True
1150
+ else:
1151
+ filters["connector_id"] = connector_value
1152
+ targets = list(Charger.objects.filter(**filters))
1153
+ if not targets:
1154
+ return
1155
+ for target in targets:
1156
+ updates: dict[str, object] = {
1157
+ "availability_request_status": status_value,
1158
+ "availability_request_status_at": now,
1159
+ "availability_request_details": details,
1160
+ }
1161
+ if requested_type:
1162
+ updates["availability_requested_state"] = requested_type
1163
+ if requested_at:
1164
+ updates["availability_requested_at"] = requested_at
1165
+ elif requested_type:
1166
+ updates["availability_requested_at"] = now
1167
+ if status_value == "Accepted" and requested_type:
1168
+ updates["availability_state"] = requested_type
1169
+ updates["availability_state_updated_at"] = now
1170
+ Charger.objects.filter(pk=target.pk).update(**updates)
1171
+ for field, value in updates.items():
1172
+ setattr(target, field, value)
1173
+ if self.charger and self.charger.pk == target.pk:
1174
+ for field, value in updates.items():
1175
+ setattr(self.charger, field, value)
1176
+ if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
1177
+ for field, value in updates.items():
1178
+ setattr(self.aggregate_charger, field, value)
1179
+
1180
+ await database_sync_to_async(_apply)()
1181
+
1182
+ async def _update_availability_state(
1183
+ self,
1184
+ state: str,
1185
+ timestamp: datetime,
1186
+ connector_value: int | None,
1187
+ ) -> None:
1188
+ def _apply():
1189
+ filters: dict[str, object] = {"charger_id": self.charger_id}
1190
+ if connector_value is None:
1191
+ filters["connector_id__isnull"] = True
1192
+ else:
1193
+ filters["connector_id"] = connector_value
1194
+ updates = {
1195
+ "availability_state": state,
1196
+ "availability_state_updated_at": timestamp,
1197
+ }
1198
+ targets = list(Charger.objects.filter(**filters))
1199
+ if not targets:
1200
+ return
1201
+ Charger.objects.filter(pk__in=[target.pk for target in targets]).update(
1202
+ **updates
1203
+ )
1204
+ for target in targets:
1205
+ for field, value in updates.items():
1206
+ setattr(target, field, value)
1207
+ if self.charger and self.charger.pk == target.pk:
1208
+ for field, value in updates.items():
1209
+ setattr(self.charger, field, value)
1210
+ if self.aggregate_charger and self.aggregate_charger.pk == target.pk:
1211
+ for field, value in updates.items():
1212
+ setattr(self.aggregate_charger, field, value)
1213
+
1214
+ await database_sync_to_async(_apply)()
1215
+
1216
+ async def disconnect(self, close_code):
1217
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
1218
+ tx_obj = None
1219
+ if self.charger_id:
1220
+ tx_obj = store.get_transaction(self.charger_id, self.connector_value)
1221
+ if tx_obj:
1222
+ await self._update_consumption_message(tx_obj.pk)
1223
+ await self._cancel_consumption_message()
1224
+ store.connections.pop(self.store_key, None)
1225
+ pending_key = store.pending_key(self.charger_id)
1226
+ if self.store_key != pending_key:
1227
+ store.connections.pop(pending_key, None)
1228
+ store.end_session_log(self.store_key)
1229
+ store.stop_session_lock()
1230
+ store.clear_pending_calls(self.charger_id)
1231
+ store.add_log(self.store_key, f"Closed (code={close_code})", log_type="charger")
1232
+
1233
+ async def receive(self, text_data=None, bytes_data=None):
1234
+ raw = text_data
1235
+ if raw is None and bytes_data is not None:
1236
+ raw = base64.b64encode(bytes_data).decode("ascii")
1237
+ if raw is None:
1238
+ return
1239
+ store.add_log(self.store_key, raw, log_type="charger")
1240
+ store.add_session_message(self.store_key, raw)
1241
+ try:
1242
+ msg = json.loads(raw)
1243
+ except json.JSONDecodeError:
1244
+ return
1245
+ if not isinstance(msg, list) or not msg:
1246
+ return
1247
+ message_type = msg[0]
1248
+ if message_type == 2:
1249
+ msg_id, action = msg[1], msg[2]
1250
+ payload = msg[3] if len(msg) > 3 else {}
1251
+ reply_payload = {}
1252
+ connector_hint = None
1253
+ if isinstance(payload, dict):
1254
+ connector_hint = payload.get("connectorId")
1255
+ follow_up = store.consume_triggered_followup(
1256
+ self.charger_id, action, connector_hint
1257
+ )
1258
+ if follow_up:
1259
+ follow_up_log_key = follow_up.get("log_key") or self.store_key
1260
+ target_label = follow_up.get("target") or action
1261
+ connector_slug_value = follow_up.get("connector")
1262
+ suffix = ""
1263
+ if (
1264
+ connector_slug_value
1265
+ and connector_slug_value != store.AGGREGATE_SLUG
1266
+ ):
1267
+ suffix = f" (connector {connector_slug_value})"
1268
+ store.add_log(
1269
+ follow_up_log_key,
1270
+ f"TriggerMessage follow-up received: {target_label}{suffix}",
1271
+ log_type="charger",
1272
+ )
1273
+ await self._assign_connector(payload.get("connectorId"))
1274
+ if action == "BootNotification":
1275
+ reply_payload = {
1276
+ "currentTime": datetime.utcnow().isoformat() + "Z",
1277
+ "interval": 300,
1278
+ "status": "Accepted",
1279
+ }
1280
+ elif action == "DataTransfer":
1281
+ reply_payload = await self._handle_data_transfer(msg_id, payload)
1282
+ elif action == "Heartbeat":
1283
+ reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
1284
+ now = timezone.now()
1285
+ self.charger.last_heartbeat = now
1286
+ await database_sync_to_async(
1287
+ Charger.objects.filter(pk=self.charger.pk).update
1288
+ )(last_heartbeat=now)
1289
+ elif action == "StatusNotification":
1290
+ await self._assign_connector(payload.get("connectorId"))
1291
+ status = (payload.get("status") or "").strip()
1292
+ error_code = (payload.get("errorCode") or "").strip()
1293
+ vendor_info = {
1294
+ key: value
1295
+ for key, value in (
1296
+ ("info", payload.get("info")),
1297
+ ("vendorId", payload.get("vendorId")),
1298
+ )
1299
+ if value
1300
+ }
1301
+ vendor_value = vendor_info or None
1302
+ timestamp_raw = payload.get("timestamp")
1303
+ status_timestamp = (
1304
+ parse_datetime(timestamp_raw) if timestamp_raw else None
1305
+ )
1306
+ if status_timestamp is None:
1307
+ status_timestamp = timezone.now()
1308
+ elif timezone.is_naive(status_timestamp):
1309
+ status_timestamp = timezone.make_aware(status_timestamp)
1310
+ update_kwargs = {
1311
+ "last_status": status,
1312
+ "last_error_code": error_code,
1313
+ "last_status_vendor_info": vendor_value,
1314
+ "last_status_timestamp": status_timestamp,
1315
+ }
1316
+
1317
+ def _update_instance(instance: Charger | None) -> None:
1318
+ if not instance:
1319
+ return
1320
+ instance.last_status = status
1321
+ instance.last_error_code = error_code
1322
+ instance.last_status_vendor_info = vendor_value
1323
+ instance.last_status_timestamp = status_timestamp
1324
+
1325
+ await database_sync_to_async(
1326
+ Charger.objects.filter(
1327
+ charger_id=self.charger_id, connector_id=None
1328
+ ).update
1329
+ )(**update_kwargs)
1330
+ connector_value = self.connector_value
1331
+ if connector_value is not None:
1332
+ await database_sync_to_async(
1333
+ Charger.objects.filter(
1334
+ charger_id=self.charger_id,
1335
+ connector_id=connector_value,
1336
+ ).update
1337
+ )(**update_kwargs)
1338
+ _update_instance(self.aggregate_charger)
1339
+ _update_instance(self.charger)
1340
+ if connector_value is not None and status.lower() == "available":
1341
+ tx_obj = store.transactions.pop(self.store_key, None)
1342
+ if tx_obj:
1343
+ await self._cancel_consumption_message()
1344
+ store.end_session_log(self.store_key)
1345
+ store.stop_session_lock()
1346
+ store.add_log(
1347
+ self.store_key,
1348
+ f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
1349
+ log_type="charger",
1350
+ )
1351
+ availability_state = Charger.availability_state_from_status(status)
1352
+ if availability_state:
1353
+ await self._update_availability_state(
1354
+ availability_state, status_timestamp, self.connector_value
1355
+ )
1356
+ reply_payload = {}
1357
+ elif action == "Authorize":
1358
+ id_tag = payload.get("idTag")
1359
+ account = await self._get_account(id_tag)
1360
+ if self.charger.require_rfid:
1361
+ status = (
1362
+ "Accepted"
1363
+ if account
1364
+ and await database_sync_to_async(account.can_authorize)()
1365
+ else "Invalid"
1366
+ )
1367
+ else:
1368
+ await self._ensure_rfid_seen(id_tag)
1369
+ status = "Accepted"
1370
+ reply_payload = {"idTagInfo": {"status": status}}
1371
+ elif action == "MeterValues":
1372
+ await self._store_meter_values(payload, text_data)
1373
+ self.charger.last_meter_values = payload
1374
+ await database_sync_to_async(
1375
+ Charger.objects.filter(pk=self.charger.pk).update
1376
+ )(last_meter_values=payload)
1377
+ reply_payload = {}
1378
+ elif action == "DiagnosticsStatusNotification":
1379
+ status_value = payload.get("status")
1380
+ location_value = (
1381
+ payload.get("uploadLocation")
1382
+ or payload.get("location")
1383
+ or payload.get("uri")
1384
+ )
1385
+ timestamp_value = payload.get("timestamp")
1386
+ diagnostics_timestamp = None
1387
+ if timestamp_value:
1388
+ diagnostics_timestamp = parse_datetime(timestamp_value)
1389
+ if diagnostics_timestamp and timezone.is_naive(
1390
+ diagnostics_timestamp
1391
+ ):
1392
+ diagnostics_timestamp = timezone.make_aware(
1393
+ diagnostics_timestamp, timezone=timezone.utc
1394
+ )
1395
+
1396
+ updates = {
1397
+ "diagnostics_status": status_value or None,
1398
+ "diagnostics_timestamp": diagnostics_timestamp,
1399
+ "diagnostics_location": location_value or None,
1400
+ }
1401
+
1402
+ def _persist_diagnostics():
1403
+ targets: list[Charger] = []
1404
+ if self.charger:
1405
+ targets.append(self.charger)
1406
+ aggregate = self.aggregate_charger
1407
+ if (
1408
+ aggregate
1409
+ and not any(
1410
+ target.pk == aggregate.pk for target in targets if target.pk
1411
+ )
1412
+ ):
1413
+ targets.append(aggregate)
1414
+ for target in targets:
1415
+ for field, value in updates.items():
1416
+ setattr(target, field, value)
1417
+ if target.pk:
1418
+ Charger.objects.filter(pk=target.pk).update(**updates)
1419
+
1420
+ await database_sync_to_async(_persist_diagnostics)()
1421
+
1422
+ status_label = updates["diagnostics_status"] or "unknown"
1423
+ log_message = "DiagnosticsStatusNotification: status=%s" % (
1424
+ status_label,
1425
+ )
1426
+ if updates["diagnostics_timestamp"]:
1427
+ log_message += ", timestamp=%s" % (
1428
+ updates["diagnostics_timestamp"].isoformat()
1429
+ )
1430
+ if updates["diagnostics_location"]:
1431
+ log_message += ", location=%s" % updates["diagnostics_location"]
1432
+ store.add_log(self.store_key, log_message, log_type="charger")
1433
+ if self.aggregate_charger and self.aggregate_charger.connector_id is None:
1434
+ aggregate_key = store.identity_key(self.charger_id, None)
1435
+ if aggregate_key != self.store_key:
1436
+ store.add_log(aggregate_key, log_message, log_type="charger")
1437
+ reply_payload = {}
1438
+ elif action == "StartTransaction":
1439
+ id_tag = payload.get("idTag")
1440
+ account = await self._get_account(id_tag)
1441
+ if id_tag:
1442
+ if self.charger.require_rfid:
1443
+ await database_sync_to_async(CoreRFID.register_scan)(
1444
+ id_tag.upper()
1445
+ )
1446
+ else:
1447
+ await self._ensure_rfid_seen(id_tag)
1448
+ await self._assign_connector(payload.get("connectorId"))
1449
+ if self.charger.require_rfid:
1450
+ authorized = (
1451
+ account is not None
1452
+ and await database_sync_to_async(account.can_authorize)()
1453
+ )
1454
+ else:
1455
+ authorized = True
1456
+ if authorized:
1457
+ start_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1458
+ received_start = timezone.now()
1459
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
1460
+ charger=self.charger,
1461
+ account=account,
1462
+ rfid=(id_tag or ""),
1463
+ vin=(payload.get("vin") or ""),
1464
+ connector_id=payload.get("connectorId"),
1465
+ meter_start=payload.get("meterStart"),
1466
+ start_time=start_timestamp or received_start,
1467
+ received_start_time=received_start,
1468
+ )
1469
+ store.transactions[self.store_key] = tx_obj
1470
+ store.start_session_log(self.store_key, tx_obj.pk)
1471
+ store.start_session_lock()
1472
+ store.add_session_message(self.store_key, text_data)
1473
+ await self._start_consumption_updates(tx_obj)
1474
+ reply_payload = {
1475
+ "transactionId": tx_obj.pk,
1476
+ "idTagInfo": {"status": "Accepted"},
1477
+ }
1478
+ else:
1479
+ reply_payload = {"idTagInfo": {"status": "Invalid"}}
1480
+ elif action == "StopTransaction":
1481
+ tx_id = payload.get("transactionId")
1482
+ tx_obj = store.transactions.pop(self.store_key, None)
1483
+ if not tx_obj and tx_id is not None:
1484
+ tx_obj = await database_sync_to_async(
1485
+ Transaction.objects.filter(pk=tx_id, charger=self.charger).first
1486
+ )()
1487
+ if not tx_obj and tx_id is not None:
1488
+ received_start = timezone.now()
1489
+ tx_obj = await database_sync_to_async(Transaction.objects.create)(
1490
+ pk=tx_id,
1491
+ charger=self.charger,
1492
+ start_time=received_start,
1493
+ received_start_time=received_start,
1494
+ meter_start=payload.get("meterStart")
1495
+ or payload.get("meterStop"),
1496
+ vin=(payload.get("vin") or ""),
1497
+ )
1498
+ if tx_obj:
1499
+ stop_timestamp = _parse_ocpp_timestamp(payload.get("timestamp"))
1500
+ received_stop = timezone.now()
1501
+ tx_obj.meter_stop = payload.get("meterStop")
1502
+ tx_obj.stop_time = stop_timestamp or received_stop
1503
+ tx_obj.received_stop_time = received_stop
1504
+ await database_sync_to_async(tx_obj.save)()
1505
+ await self._update_consumption_message(tx_obj.pk)
1506
+ await self._cancel_consumption_message()
1507
+ reply_payload = {"idTagInfo": {"status": "Accepted"}}
1508
+ store.end_session_log(self.store_key)
1509
+ store.stop_session_lock()
1510
+ elif action == "FirmwareStatusNotification":
1511
+ status_raw = payload.get("status")
1512
+ status = str(status_raw or "").strip()
1513
+ info_value = payload.get("statusInfo")
1514
+ if not isinstance(info_value, str):
1515
+ info_value = payload.get("info")
1516
+ status_info = str(info_value or "").strip()
1517
+ timestamp_raw = payload.get("timestamp")
1518
+ timestamp_value = None
1519
+ if timestamp_raw:
1520
+ timestamp_value = parse_datetime(str(timestamp_raw))
1521
+ if timestamp_value and timezone.is_naive(timestamp_value):
1522
+ timestamp_value = timezone.make_aware(
1523
+ timestamp_value, timezone.get_current_timezone()
1524
+ )
1525
+ if timestamp_value is None:
1526
+ timestamp_value = timezone.now()
1527
+ await self._update_firmware_state(
1528
+ status, status_info, timestamp_value
1529
+ )
1530
+ store.add_log(
1531
+ self.store_key,
1532
+ "FirmwareStatusNotification: "
1533
+ + json.dumps(payload, separators=(",", ":")),
1534
+ log_type="charger",
1535
+ )
1536
+ if (
1537
+ self.aggregate_charger
1538
+ and self.aggregate_charger.connector_id is None
1539
+ ):
1540
+ aggregate_key = store.identity_key(
1541
+ self.charger_id, self.aggregate_charger.connector_id
1542
+ )
1543
+ if aggregate_key != self.store_key:
1544
+ store.add_log(
1545
+ aggregate_key,
1546
+ "FirmwareStatusNotification: "
1547
+ + json.dumps(payload, separators=(",", ":")),
1548
+ log_type="charger",
1549
+ )
1550
+ reply_payload = {}
1551
+ response = [3, msg_id, reply_payload]
1552
+ await self.send(json.dumps(response))
1553
+ store.add_log(
1554
+ self.store_key, f"< {json.dumps(response)}", log_type="charger"
1555
+ )
1556
+ elif message_type == 3:
1557
+ msg_id = msg[1] if len(msg) > 1 else ""
1558
+ payload = msg[2] if len(msg) > 2 else {}
1559
+ await self._handle_call_result(msg_id, payload)
1560
+ elif message_type == 4:
1561
+ msg_id = msg[1] if len(msg) > 1 else ""
1562
+ error_code = msg[2] if len(msg) > 2 else ""
1563
+ description = msg[3] if len(msg) > 3 else ""
1564
+ details = msg[4] if len(msg) > 4 else {}
1565
+ await self._handle_call_error(msg_id, error_code, description, details)