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