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