arthexis 0.1.8__py3-none-any.whl → 0.1.10__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 (84) hide show
  1. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
  2. arthexis-0.1.10.dist-info/RECORD +95 -0
  3. arthexis-0.1.10.dist-info/licenses/LICENSE +674 -0
  4. config/__init__.py +0 -1
  5. config/auth_app.py +0 -1
  6. config/celery.py +1 -2
  7. config/context_processors.py +1 -1
  8. config/offline.py +2 -0
  9. config/settings.py +352 -37
  10. config/urls.py +71 -6
  11. core/admin.py +1601 -200
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +108 -1
  14. core/apps.py +161 -3
  15. core/auto_upgrade.py +57 -0
  16. core/backends.py +123 -7
  17. core/entity.py +62 -48
  18. core/fields.py +98 -0
  19. core/github_helper.py +25 -0
  20. core/github_issues.py +172 -0
  21. core/lcd_screen.py +1 -0
  22. core/liveupdate.py +25 -0
  23. core/log_paths.py +100 -0
  24. core/mailer.py +83 -0
  25. core/middleware.py +57 -0
  26. core/models.py +1279 -267
  27. core/notifications.py +11 -1
  28. core/public_wifi.py +227 -0
  29. core/reference_utils.py +97 -0
  30. core/release.py +27 -20
  31. core/sigil_builder.py +144 -0
  32. core/sigil_context.py +20 -0
  33. core/sigil_resolver.py +284 -0
  34. core/system.py +162 -29
  35. core/tasks.py +269 -27
  36. core/test_system_info.py +59 -1
  37. core/tests.py +644 -73
  38. core/tests_liveupdate.py +17 -0
  39. core/urls.py +2 -2
  40. core/user_data.py +425 -168
  41. core/views.py +627 -59
  42. core/widgets.py +51 -0
  43. core/workgroup_urls.py +7 -3
  44. core/workgroup_views.py +43 -6
  45. nodes/actions.py +0 -2
  46. nodes/admin.py +168 -285
  47. nodes/apps.py +9 -15
  48. nodes/backends.py +145 -0
  49. nodes/lcd.py +24 -10
  50. nodes/models.py +579 -179
  51. nodes/tasks.py +1 -5
  52. nodes/tests.py +894 -130
  53. nodes/utils.py +13 -2
  54. nodes/views.py +204 -28
  55. ocpp/admin.py +212 -63
  56. ocpp/apps.py +1 -1
  57. ocpp/consumers.py +642 -68
  58. ocpp/evcs.py +30 -10
  59. ocpp/models.py +452 -70
  60. ocpp/simulator.py +75 -11
  61. ocpp/store.py +288 -30
  62. ocpp/tasks.py +11 -7
  63. ocpp/test_export_import.py +8 -7
  64. ocpp/test_rfid.py +211 -16
  65. ocpp/tests.py +1576 -137
  66. ocpp/transactions_io.py +68 -22
  67. ocpp/urls.py +35 -2
  68. ocpp/views.py +701 -123
  69. pages/admin.py +173 -13
  70. pages/checks.py +0 -1
  71. pages/context_processors.py +39 -6
  72. pages/forms.py +131 -0
  73. pages/middleware.py +153 -0
  74. pages/models.py +37 -9
  75. pages/tests.py +1182 -42
  76. pages/urls.py +4 -0
  77. pages/utils.py +0 -1
  78. pages/views.py +844 -51
  79. arthexis-0.1.8.dist-info/RECORD +0 -80
  80. arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
  81. config/workgroup_app.py +0 -7
  82. core/checks.py +0 -29
  83. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
  84. {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -1,9 +1,12 @@
1
1
  import asyncio
2
2
  import json
3
3
  import base64
4
+ import ipaddress
5
+ import re
4
6
  from datetime import datetime
5
7
  from django.utils import timezone
6
- from core.models import EnergyAccount
8
+ from core.models import EnergyAccount, Reference, RFID as CoreRFID
9
+ from nodes.models import NetMessage
7
10
 
8
11
  from channels.generic.websocket import AsyncWebsocketConsumer
9
12
  from channels.db import database_sync_to_async
@@ -13,7 +16,83 @@ from config.offline import requires_network
13
16
  from . import store
14
17
  from decimal import Decimal
15
18
  from django.utils.dateparse import parse_datetime
16
- from .models import Transaction, Charger, MeterReading
19
+ from .models import Transaction, Charger, MeterValue
20
+
21
+ FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
22
+
23
+
24
+ def _parse_ip(value: str | None):
25
+ """Return an :mod:`ipaddress` object for the provided value, if valid."""
26
+
27
+ candidate = (value or "").strip()
28
+ if not candidate or candidate.lower() == "unknown":
29
+ return None
30
+ if candidate.lower().startswith("for="):
31
+ candidate = candidate[4:].strip()
32
+ candidate = candidate.strip("'\"")
33
+ if candidate.startswith("["):
34
+ closing = candidate.find("]")
35
+ if closing != -1:
36
+ candidate = candidate[1:closing]
37
+ else:
38
+ candidate = candidate[1:]
39
+ # Remove any comma separated values that may remain.
40
+ if "," in candidate:
41
+ candidate = candidate.split(",", 1)[0].strip()
42
+ try:
43
+ parsed = ipaddress.ip_address(candidate)
44
+ except ValueError:
45
+ host, sep, maybe_port = candidate.rpartition(":")
46
+ if not sep or not maybe_port.isdigit():
47
+ return None
48
+ try:
49
+ parsed = ipaddress.ip_address(host)
50
+ except ValueError:
51
+ return None
52
+ return parsed
53
+
54
+
55
+ def _resolve_client_ip(scope: dict) -> str | None:
56
+ """Return the most useful client IP for the provided ASGI scope."""
57
+
58
+ headers = scope.get("headers") or []
59
+ header_map: dict[str, list[str]] = {}
60
+ for key_bytes, value_bytes in headers:
61
+ try:
62
+ key = key_bytes.decode("latin1").lower()
63
+ except Exception:
64
+ continue
65
+ try:
66
+ value = value_bytes.decode("latin1")
67
+ except Exception:
68
+ value = ""
69
+ header_map.setdefault(key, []).append(value)
70
+
71
+ candidates: list[str] = []
72
+ for raw in header_map.get("x-forwarded-for", []):
73
+ candidates.extend(part.strip() for part in raw.split(","))
74
+ for raw in header_map.get("forwarded", []):
75
+ for segment in raw.split(","):
76
+ match = FORWARDED_PAIR_RE.search(segment)
77
+ if match:
78
+ candidates.append(match.group("value"))
79
+ candidates.extend(header_map.get("x-real-ip", []))
80
+ client = scope.get("client")
81
+ if client:
82
+ candidates.append((client[0] or "").strip())
83
+
84
+ fallback: str | None = None
85
+ for raw in candidates:
86
+ parsed = _parse_ip(raw)
87
+ if not parsed:
88
+ continue
89
+ ip_text = str(parsed)
90
+ if parsed.is_loopback:
91
+ if fallback is None:
92
+ fallback = ip_text
93
+ continue
94
+ return ip_text
95
+ return fallback
17
96
 
18
97
 
19
98
  class SinkConsumer(AsyncWebsocketConsumer):
@@ -21,9 +100,18 @@ class SinkConsumer(AsyncWebsocketConsumer):
21
100
 
22
101
  @requires_network
23
102
  async def connect(self) -> None:
103
+ self.client_ip = _resolve_client_ip(self.scope)
104
+ if not store.register_ip_connection(self.client_ip, self):
105
+ await self.close(code=4003)
106
+ return
24
107
  await self.accept()
25
108
 
26
- async def receive(self, text_data: str | None = None, bytes_data: bytes | None = None) -> None:
109
+ async def disconnect(self, close_code):
110
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
111
+
112
+ async def receive(
113
+ self, text_data: str | None = None, bytes_data: bytes | None = None
114
+ ) -> None:
27
115
  if text_data is None:
28
116
  return
29
117
  try:
@@ -37,37 +125,64 @@ class SinkConsumer(AsyncWebsocketConsumer):
37
125
  class CSMSConsumer(AsyncWebsocketConsumer):
38
126
  """Very small subset of OCPP 1.6 CSMS behaviour."""
39
127
 
128
+ consumption_update_interval = 300
129
+
40
130
  @requires_network
41
131
  async def connect(self):
42
132
  self.charger_id = self.scope["url_route"]["kwargs"].get("cid", "")
133
+ self.connector_value: int | None = None
134
+ self.store_key = store.pending_key(self.charger_id)
135
+ self.aggregate_charger: Charger | None = None
136
+ self._consumption_task: asyncio.Task | None = None
137
+ self._consumption_message_uuid: str | None = None
43
138
  subprotocol = None
44
139
  offered = self.scope.get("subprotocols", [])
45
140
  if "ocpp1.6" in offered:
46
141
  subprotocol = "ocpp1.6"
47
- # If a connection for this charger already exists, close it so a new
48
- # simulator session can start immediately.
49
- existing = store.connections.get(self.charger_id)
142
+ self.client_ip = _resolve_client_ip(self.scope)
143
+ self._header_reference_created = False
144
+ # Close any pending connection for this charger so reconnections do
145
+ # not leak stale consumers when the connector id has not been
146
+ # negotiated yet.
147
+ existing = store.connections.get(self.store_key)
50
148
  if existing is not None:
149
+ store.release_ip_connection(getattr(existing, "client_ip", None), existing)
51
150
  await existing.close()
151
+ if not store.register_ip_connection(self.client_ip, self):
152
+ store.add_log(
153
+ self.store_key,
154
+ f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
155
+ log_type="charger",
156
+ )
157
+ await self.close(code=4003)
158
+ return
52
159
  await self.accept(subprotocol=subprotocol)
53
160
  store.add_log(
54
- self.charger_id,
161
+ self.store_key,
55
162
  f"Connected (subprotocol={subprotocol or 'none'})",
56
163
  log_type="charger",
57
164
  )
58
- store.connections[self.charger_id] = self
59
- store.logs["charger"].setdefault(self.charger_id, [])
60
- self.charger, _ = await database_sync_to_async(
61
- Charger.objects.update_or_create
165
+ store.connections[self.store_key] = self
166
+ store.logs["charger"].setdefault(self.store_key, [])
167
+ self.charger, created = await database_sync_to_async(
168
+ Charger.objects.get_or_create
62
169
  )(
63
170
  charger_id=self.charger_id,
171
+ connector_id=None,
64
172
  defaults={"last_path": self.scope.get("path", "")},
65
173
  )
174
+ await database_sync_to_async(self.charger.refresh_manager_node)()
175
+ self.aggregate_charger = self.charger
66
176
  location_name = await sync_to_async(
67
177
  lambda: self.charger.location.name if self.charger.location else ""
68
178
  )()
179
+ friendly_name = location_name or self.charger_id
180
+ store.register_log_name(self.store_key, friendly_name, log_type="charger")
181
+ store.register_log_name(self.charger_id, friendly_name, log_type="charger")
69
182
  store.register_log_name(
70
- self.charger_id, location_name or self.charger_id, log_type="charger"
183
+ store.identity_key(self.charger_id, None),
184
+ friendly_name,
185
+ log_type="charger",
71
186
  )
72
187
 
73
188
  async def _get_account(self, id_tag: str) -> EnergyAccount | None:
@@ -80,16 +195,138 @@ class CSMSConsumer(AsyncWebsocketConsumer):
80
195
  ).first
81
196
  )()
82
197
 
198
+ async def _assign_connector(self, connector: int | str | None) -> None:
199
+ """Ensure ``self.charger`` matches the provided connector id."""
200
+ if connector in (None, "", "-"):
201
+ connector_value = None
202
+ else:
203
+ try:
204
+ connector_value = int(connector)
205
+ if connector_value == 0:
206
+ connector_value = None
207
+ except (TypeError, ValueError):
208
+ return
209
+ if connector_value is None:
210
+ if not self._header_reference_created and self.client_ip:
211
+ await database_sync_to_async(self._ensure_console_reference)()
212
+ self._header_reference_created = True
213
+ return
214
+ if (
215
+ self.connector_value == connector_value
216
+ and self.charger.connector_id == connector_value
217
+ ):
218
+ return
219
+ if (
220
+ not self.aggregate_charger
221
+ or self.aggregate_charger.connector_id is not None
222
+ ):
223
+ aggregate, _ = await database_sync_to_async(
224
+ Charger.objects.get_or_create
225
+ )(
226
+ charger_id=self.charger_id,
227
+ connector_id=None,
228
+ defaults={"last_path": self.scope.get("path", "")},
229
+ )
230
+ await database_sync_to_async(aggregate.refresh_manager_node)()
231
+ self.aggregate_charger = aggregate
232
+ existing = await database_sync_to_async(
233
+ Charger.objects.filter(
234
+ charger_id=self.charger_id, connector_id=connector_value
235
+ ).first
236
+ )()
237
+ if existing:
238
+ self.charger = existing
239
+ await database_sync_to_async(self.charger.refresh_manager_node)()
240
+ else:
241
+
242
+ def _create_connector():
243
+ charger, _ = Charger.objects.get_or_create(
244
+ charger_id=self.charger_id,
245
+ connector_id=connector_value,
246
+ defaults={"last_path": self.scope.get("path", "")},
247
+ )
248
+ if self.scope.get("path") and charger.last_path != self.scope.get(
249
+ "path"
250
+ ):
251
+ charger.last_path = self.scope.get("path")
252
+ charger.save(update_fields=["last_path"])
253
+ charger.refresh_manager_node()
254
+ return charger
255
+
256
+ self.charger = await database_sync_to_async(_create_connector)()
257
+ previous_key = self.store_key
258
+ new_key = store.identity_key(self.charger_id, connector_value)
259
+ if previous_key != new_key:
260
+ existing_consumer = store.connections.get(new_key)
261
+ if existing_consumer is not None and existing_consumer is not self:
262
+ await existing_consumer.close()
263
+ store.reassign_identity(previous_key, new_key)
264
+ store.connections[new_key] = self
265
+ store.logs["charger"].setdefault(new_key, [])
266
+ connector_name = await sync_to_async(
267
+ lambda: self.charger.name or self.charger.charger_id
268
+ )()
269
+ store.register_log_name(new_key, connector_name, log_type="charger")
270
+ aggregate_name = ""
271
+ if self.aggregate_charger:
272
+ aggregate_name = await sync_to_async(
273
+ lambda: self.aggregate_charger.name or self.aggregate_charger.charger_id
274
+ )()
275
+ store.register_log_name(
276
+ store.identity_key(self.charger_id, None),
277
+ aggregate_name or self.charger_id,
278
+ log_type="charger",
279
+ )
280
+ self.store_key = new_key
281
+ self.connector_value = connector_value
282
+
283
+ def _ensure_console_reference(self) -> None:
284
+ """Create or update a header reference for the connected charger."""
285
+
286
+ ip = (self.client_ip or "").strip()
287
+ serial = (self.charger_id or "").strip()
288
+ if not ip or not serial:
289
+ return
290
+ host = ip
291
+ if ":" in host and not host.startswith("["):
292
+ host = f"[{host}]"
293
+ url = f"http://{host}:8900"
294
+ alt_text = f"{serial} Console"
295
+ reference, _ = Reference.objects.get_or_create(
296
+ alt_text=alt_text,
297
+ defaults={
298
+ "value": url,
299
+ "show_in_header": True,
300
+ "method": "link",
301
+ },
302
+ )
303
+ updated_fields: list[str] = []
304
+ if reference.value != url:
305
+ reference.value = url
306
+ updated_fields.append("value")
307
+ if reference.method != "link":
308
+ reference.method = "link"
309
+ updated_fields.append("method")
310
+ if not reference.show_in_header:
311
+ reference.show_in_header = True
312
+ updated_fields.append("show_in_header")
313
+ if updated_fields:
314
+ reference.save(update_fields=updated_fields)
315
+
83
316
  async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
84
- """Parse a MeterValues payload into MeterReading rows."""
85
- connector = payload.get("connectorId")
317
+ """Parse a MeterValues payload into MeterValue rows."""
318
+ connector_raw = payload.get("connectorId")
319
+ connector_value = None
320
+ if connector_raw is not None:
321
+ try:
322
+ connector_value = int(connector_raw)
323
+ except (TypeError, ValueError):
324
+ connector_value = None
325
+ await self._assign_connector(connector_value)
86
326
  tx_id = payload.get("transactionId")
87
327
  tx_obj = None
88
328
  if tx_id is not None:
89
- # Look up an existing transaction, first in the in-memory store
90
- # then in the database. If none exists create one so that meter
91
- # readings can be linked to it.
92
- tx_obj = store.transactions.get(self.charger_id)
329
+ tx_obj = store.transactions.get(self.store_key)
93
330
  if not tx_obj or tx_obj.pk != int(tx_id):
94
331
  tx_obj = await database_sync_to_async(
95
332
  Transaction.objects.filter(pk=tx_id, charger=self.charger).first
@@ -98,57 +335,87 @@ class CSMSConsumer(AsyncWebsocketConsumer):
98
335
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
99
336
  pk=tx_id, charger=self.charger, start_time=timezone.now()
100
337
  )
101
- store.start_session_log(self.charger_id, tx_obj.pk)
102
- store.add_session_message(self.charger_id, raw_message)
103
- store.transactions[self.charger_id] = tx_obj
338
+ store.start_session_log(self.store_key, tx_obj.pk)
339
+ store.add_session_message(self.store_key, raw_message)
340
+ store.transactions[self.store_key] = tx_obj
104
341
  else:
105
- tx_obj = store.transactions.get(self.charger_id)
342
+ tx_obj = store.transactions.get(self.store_key)
106
343
 
107
344
  readings = []
108
- start_updated = False
345
+ updated_fields: set[str] = set()
109
346
  temperature = None
110
347
  temp_unit = ""
111
348
  for mv in payload.get("meterValue", []):
112
349
  ts = parse_datetime(mv.get("timestamp"))
350
+ values: dict[str, Decimal] = {}
351
+ context = ""
113
352
  for sv in mv.get("sampledValue", []):
114
353
  try:
115
354
  val = Decimal(str(sv.get("value")))
116
355
  except Exception:
117
356
  continue
118
- if (
119
- tx_obj
120
- and tx_obj.meter_start is None
121
- and sv.get("measurand", "") in ("", "Energy.Active.Import.Register")
122
- ):
123
- try:
124
- mult = 1000 if sv.get("unit") == "kW" else 1
125
- tx_obj.meter_start = int(val * mult)
126
- start_updated = True
127
- except Exception:
128
- pass
357
+ context = sv.get("context", context or "")
129
358
  measurand = sv.get("measurand", "")
130
359
  unit = sv.get("unit", "")
131
- if measurand == "Temperature":
360
+ field = None
361
+ if measurand in ("", "Energy.Active.Import.Register"):
362
+ field = "energy"
363
+ if unit == "Wh":
364
+ val = val / Decimal("1000")
365
+ elif measurand == "Voltage":
366
+ field = "voltage"
367
+ elif measurand == "Current.Import":
368
+ field = "current_import"
369
+ elif measurand == "Current.Offered":
370
+ field = "current_offered"
371
+ elif measurand == "Temperature":
372
+ field = "temperature"
132
373
  temperature = val
133
374
  temp_unit = unit
375
+ elif measurand == "SoC":
376
+ field = "soc"
377
+ if field:
378
+ if tx_obj and context in ("Transaction.Begin", "Transaction.End"):
379
+ suffix = "start" if context == "Transaction.Begin" else "stop"
380
+ if field == "energy":
381
+ mult = 1000 if unit in ("kW", "kWh") else 1
382
+ setattr(tx_obj, f"meter_{suffix}", int(val * mult))
383
+ updated_fields.add(f"meter_{suffix}")
384
+ else:
385
+ setattr(tx_obj, f"{field}_{suffix}", val)
386
+ updated_fields.add(f"{field}_{suffix}")
387
+ else:
388
+ values[field] = val
389
+ if tx_obj and field == "energy" and tx_obj.meter_start is None:
390
+ mult = 1000 if unit in ("kW", "kWh") else 1
391
+ try:
392
+ tx_obj.meter_start = int(val * mult)
393
+ except (TypeError, ValueError):
394
+ pass
395
+ else:
396
+ updated_fields.add("meter_start")
397
+ if values and context not in ("Transaction.Begin", "Transaction.End"):
134
398
  readings.append(
135
- MeterReading(
399
+ MeterValue(
136
400
  charger=self.charger,
137
- connector_id=connector,
401
+ connector_id=connector_value,
138
402
  transaction=tx_obj,
139
403
  timestamp=ts,
140
- measurand=measurand,
141
- value=val,
142
- unit=unit,
404
+ context=context,
405
+ **values,
143
406
  )
144
407
  )
145
408
  if readings:
146
- await database_sync_to_async(MeterReading.objects.bulk_create)(readings)
147
- if tx_obj and start_updated:
148
- await database_sync_to_async(tx_obj.save)(update_fields=["meter_start"])
149
- if connector is not None and not self.charger.connector_id:
150
- self.charger.connector_id = str(connector)
151
- await database_sync_to_async(self.charger.save)(update_fields=["connector_id"])
409
+ await database_sync_to_async(MeterValue.objects.bulk_create)(readings)
410
+ if tx_obj and updated_fields:
411
+ await database_sync_to_async(tx_obj.save)(
412
+ update_fields=list(updated_fields)
413
+ )
414
+ if connector_value is not None and not self.charger.connector_id:
415
+ self.charger.connector_id = connector_value
416
+ await database_sync_to_async(self.charger.save)(
417
+ update_fields=["connector_id"]
418
+ )
152
419
  if temperature is not None:
153
420
  self.charger.temperature = temperature
154
421
  self.charger.temperature_unit = temp_unit
@@ -156,12 +423,148 @@ class CSMSConsumer(AsyncWebsocketConsumer):
156
423
  update_fields=["temperature", "temperature_unit"]
157
424
  )
158
425
 
426
+ async def _update_firmware_state(
427
+ self, status: str, status_info: str, timestamp: datetime | None
428
+ ) -> None:
429
+ """Persist firmware status fields for the active charger identities."""
430
+
431
+ targets: list[Charger] = []
432
+ seen_ids: set[int] = set()
433
+ for charger in (self.charger, self.aggregate_charger):
434
+ if not charger or charger.pk is None:
435
+ continue
436
+ if charger.pk in seen_ids:
437
+ continue
438
+ targets.append(charger)
439
+ seen_ids.add(charger.pk)
440
+
441
+ if not targets:
442
+ return
443
+
444
+ def _persist(ids: list[int]) -> None:
445
+ Charger.objects.filter(pk__in=ids).update(
446
+ firmware_status=status,
447
+ firmware_status_info=status_info,
448
+ firmware_timestamp=timestamp,
449
+ )
450
+
451
+ await database_sync_to_async(_persist)([target.pk for target in targets])
452
+ for target in targets:
453
+ target.firmware_status = status
454
+ target.firmware_status_info = status_info
455
+ target.firmware_timestamp = timestamp
456
+
457
+ async def _cancel_consumption_message(self) -> None:
458
+ """Stop any scheduled consumption message updates."""
459
+
460
+ task = self._consumption_task
461
+ self._consumption_task = None
462
+ if task:
463
+ task.cancel()
464
+ try:
465
+ await task
466
+ except asyncio.CancelledError:
467
+ pass
468
+ self._consumption_message_uuid = None
469
+
470
+ async def _update_consumption_message(self, tx_id: int) -> str | None:
471
+ """Create or update the Net Message for an active transaction."""
472
+
473
+ existing_uuid = self._consumption_message_uuid
474
+
475
+ def _persist() -> str | None:
476
+ tx = (
477
+ Transaction.objects.select_related("charger")
478
+ .filter(pk=tx_id)
479
+ .first()
480
+ )
481
+ if not tx:
482
+ return None
483
+ charger = tx.charger or self.charger
484
+ serial = ""
485
+ if charger and charger.charger_id:
486
+ serial = charger.charger_id
487
+ elif self.charger_id:
488
+ serial = self.charger_id
489
+ serial = serial[:64]
490
+ if not serial:
491
+ return None
492
+ now_local = timezone.localtime(timezone.now())
493
+ body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
494
+ if existing_uuid:
495
+ msg = NetMessage.objects.filter(uuid=existing_uuid).first()
496
+ if msg:
497
+ msg.subject = serial
498
+ msg.body = body_value
499
+ msg.save(update_fields=["subject", "body"])
500
+ msg.propagate()
501
+ return str(msg.uuid)
502
+ msg = NetMessage.broadcast(subject=serial, body=body_value)
503
+ return str(msg.uuid)
504
+
505
+ try:
506
+ result = await database_sync_to_async(_persist)()
507
+ except Exception as exc: # pragma: no cover - unexpected errors
508
+ store.add_log(
509
+ self.store_key,
510
+ f"Failed to broadcast consumption message: {exc}",
511
+ log_type="charger",
512
+ )
513
+ return None
514
+ if result is None:
515
+ store.add_log(
516
+ self.store_key,
517
+ "Unable to broadcast consumption message: missing data",
518
+ log_type="charger",
519
+ )
520
+ return None
521
+ self._consumption_message_uuid = result
522
+ return result
523
+
524
+ async def _consumption_message_loop(self, tx_id: int) -> None:
525
+ """Periodically refresh the consumption Net Message."""
526
+
527
+ try:
528
+ while True:
529
+ await asyncio.sleep(self.consumption_update_interval)
530
+ updated = await self._update_consumption_message(tx_id)
531
+ if not updated:
532
+ break
533
+ except asyncio.CancelledError:
534
+ pass
535
+ except Exception as exc: # pragma: no cover - unexpected errors
536
+ store.add_log(
537
+ self.store_key,
538
+ f"Failed to refresh consumption message: {exc}",
539
+ log_type="charger",
540
+ )
541
+
542
+ async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
543
+ """Send the initial consumption message and schedule updates."""
544
+
545
+ await self._cancel_consumption_message()
546
+ initial = await self._update_consumption_message(tx_obj.pk)
547
+ if not initial:
548
+ return
549
+ task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
550
+ task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
551
+ self._consumption_task = task
552
+
159
553
  async def disconnect(self, close_code):
160
- store.connections.pop(self.charger_id, None)
161
- store.end_session_log(self.charger_id)
162
- store.add_log(
163
- self.charger_id, f"Closed (code={close_code})", log_type="charger"
164
- )
554
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
555
+ tx_obj = None
556
+ if self.charger_id:
557
+ tx_obj = store.get_transaction(self.charger_id, self.connector_value)
558
+ if tx_obj:
559
+ await self._update_consumption_message(tx_obj.pk)
560
+ await self._cancel_consumption_message()
561
+ store.connections.pop(self.store_key, None)
562
+ pending_key = store.pending_key(self.charger_id)
563
+ if self.store_key != pending_key:
564
+ store.connections.pop(pending_key, None)
565
+ store.end_session_log(self.store_key)
566
+ store.stop_session_lock()
567
+ store.add_log(self.store_key, f"Closed (code={close_code})", log_type="charger")
165
568
 
166
569
  async def receive(self, text_data=None, bytes_data=None):
167
570
  raw = text_data
@@ -169,8 +572,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
169
572
  raw = base64.b64encode(bytes_data).decode("ascii")
170
573
  if raw is None:
171
574
  return
172
- store.add_log(self.charger_id, raw, log_type="charger")
173
- store.add_session_message(self.charger_id, raw)
575
+ store.add_log(self.store_key, raw, log_type="charger")
576
+ store.add_session_message(self.store_key, raw)
174
577
  try:
175
578
  msg = json.loads(raw)
176
579
  except json.JSONDecodeError:
@@ -179,6 +582,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
179
582
  msg_id, action = msg[1], msg[2]
180
583
  payload = msg[3] if len(msg) > 3 else {}
181
584
  reply_payload = {}
585
+ await self._assign_connector(payload.get("connectorId"))
182
586
  if action == "BootNotification":
183
587
  reply_payload = {
184
588
  "currentTime": datetime.utcnow().isoformat() + "Z",
@@ -186,20 +590,76 @@ class CSMSConsumer(AsyncWebsocketConsumer):
186
590
  "status": "Accepted",
187
591
  }
188
592
  elif action == "Heartbeat":
189
- reply_payload = {
190
- "currentTime": datetime.utcnow().isoformat() + "Z"
191
- }
593
+ reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
192
594
  now = timezone.now()
193
595
  self.charger.last_heartbeat = now
194
596
  await database_sync_to_async(
195
- Charger.objects.filter(charger_id=self.charger_id).update
597
+ Charger.objects.filter(pk=self.charger.pk).update
196
598
  )(last_heartbeat=now)
599
+ elif action == "StatusNotification":
600
+ await self._assign_connector(payload.get("connectorId"))
601
+ status = (payload.get("status") or "").strip()
602
+ error_code = (payload.get("errorCode") or "").strip()
603
+ vendor_info = {
604
+ key: value
605
+ for key, value in (
606
+ ("info", payload.get("info")),
607
+ ("vendorId", payload.get("vendorId")),
608
+ )
609
+ if value
610
+ }
611
+ vendor_value = vendor_info or None
612
+ timestamp_raw = payload.get("timestamp")
613
+ status_timestamp = (
614
+ parse_datetime(timestamp_raw) if timestamp_raw else None
615
+ )
616
+ if status_timestamp is None:
617
+ status_timestamp = timezone.now()
618
+ elif timezone.is_naive(status_timestamp):
619
+ status_timestamp = timezone.make_aware(status_timestamp)
620
+ update_kwargs = {
621
+ "last_status": status,
622
+ "last_error_code": error_code,
623
+ "last_status_vendor_info": vendor_value,
624
+ "last_status_timestamp": status_timestamp,
625
+ }
626
+
627
+ def _update_instance(instance: Charger | None) -> None:
628
+ if not instance:
629
+ return
630
+ instance.last_status = status
631
+ instance.last_error_code = error_code
632
+ instance.last_status_vendor_info = vendor_value
633
+ instance.last_status_timestamp = status_timestamp
634
+
635
+ await database_sync_to_async(
636
+ Charger.objects.filter(
637
+ charger_id=self.charger_id, connector_id=None
638
+ ).update
639
+ )(**update_kwargs)
640
+ connector_value = self.connector_value
641
+ if connector_value is not None:
642
+ await database_sync_to_async(
643
+ Charger.objects.filter(
644
+ charger_id=self.charger_id,
645
+ connector_id=connector_value,
646
+ ).update
647
+ )(**update_kwargs)
648
+ _update_instance(self.aggregate_charger)
649
+ _update_instance(self.charger)
650
+ store.add_log(
651
+ self.store_key,
652
+ f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
653
+ log_type="charger",
654
+ )
655
+ reply_payload = {}
197
656
  elif action == "Authorize":
198
657
  account = await self._get_account(payload.get("idTag"))
199
658
  if self.charger.require_rfid:
200
659
  status = (
201
660
  "Accepted"
202
- if account and await database_sync_to_async(account.can_authorize)()
661
+ if account
662
+ and await database_sync_to_async(account.can_authorize)()
203
663
  else "Invalid"
204
664
  )
205
665
  else:
@@ -209,11 +669,77 @@ class CSMSConsumer(AsyncWebsocketConsumer):
209
669
  await self._store_meter_values(payload, text_data)
210
670
  self.charger.last_meter_values = payload
211
671
  await database_sync_to_async(
212
- Charger.objects.filter(charger_id=self.charger_id).update
672
+ Charger.objects.filter(pk=self.charger.pk).update
213
673
  )(last_meter_values=payload)
214
674
  reply_payload = {}
675
+ elif action == "DiagnosticsStatusNotification":
676
+ status_value = payload.get("status")
677
+ location_value = (
678
+ payload.get("uploadLocation")
679
+ or payload.get("location")
680
+ or payload.get("uri")
681
+ )
682
+ timestamp_value = payload.get("timestamp")
683
+ diagnostics_timestamp = None
684
+ if timestamp_value:
685
+ diagnostics_timestamp = parse_datetime(timestamp_value)
686
+ if diagnostics_timestamp and timezone.is_naive(
687
+ diagnostics_timestamp
688
+ ):
689
+ diagnostics_timestamp = timezone.make_aware(
690
+ diagnostics_timestamp, timezone=timezone.utc
691
+ )
692
+
693
+ updates = {
694
+ "diagnostics_status": status_value or None,
695
+ "diagnostics_timestamp": diagnostics_timestamp,
696
+ "diagnostics_location": location_value or None,
697
+ }
698
+
699
+ def _persist_diagnostics():
700
+ targets: list[Charger] = []
701
+ if self.charger:
702
+ targets.append(self.charger)
703
+ aggregate = self.aggregate_charger
704
+ if (
705
+ aggregate
706
+ and not any(
707
+ target.pk == aggregate.pk for target in targets if target.pk
708
+ )
709
+ ):
710
+ targets.append(aggregate)
711
+ for target in targets:
712
+ for field, value in updates.items():
713
+ setattr(target, field, value)
714
+ if target.pk:
715
+ Charger.objects.filter(pk=target.pk).update(**updates)
716
+
717
+ await database_sync_to_async(_persist_diagnostics)()
718
+
719
+ status_label = updates["diagnostics_status"] or "unknown"
720
+ log_message = "DiagnosticsStatusNotification: status=%s" % (
721
+ status_label,
722
+ )
723
+ if updates["diagnostics_timestamp"]:
724
+ log_message += ", timestamp=%s" % (
725
+ updates["diagnostics_timestamp"].isoformat()
726
+ )
727
+ if updates["diagnostics_location"]:
728
+ log_message += ", location=%s" % updates["diagnostics_location"]
729
+ store.add_log(self.store_key, log_message, log_type="charger")
730
+ if self.aggregate_charger and self.aggregate_charger.connector_id is None:
731
+ aggregate_key = store.identity_key(self.charger_id, None)
732
+ if aggregate_key != self.store_key:
733
+ store.add_log(aggregate_key, log_message, log_type="charger")
734
+ reply_payload = {}
215
735
  elif action == "StartTransaction":
216
- account = await self._get_account(payload.get("idTag"))
736
+ id_tag = payload.get("idTag")
737
+ account = await self._get_account(id_tag)
738
+ if id_tag:
739
+ await database_sync_to_async(CoreRFID.objects.get_or_create)(
740
+ rfid=id_tag.upper()
741
+ )
742
+ await self._assign_connector(payload.get("connectorId"))
217
743
  if self.charger.require_rfid:
218
744
  authorized = (
219
745
  account is not None
@@ -225,14 +751,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
225
751
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
226
752
  charger=self.charger,
227
753
  account=account,
228
- rfid=(payload.get("idTag") or ""),
754
+ rfid=(id_tag or ""),
229
755
  vin=(payload.get("vin") or ""),
756
+ connector_id=payload.get("connectorId"),
230
757
  meter_start=payload.get("meterStart"),
231
758
  start_time=timezone.now(),
232
759
  )
233
- store.transactions[self.charger_id] = tx_obj
234
- store.start_session_log(self.charger_id, tx_obj.pk)
235
- store.add_session_message(self.charger_id, text_data)
760
+ store.transactions[self.store_key] = tx_obj
761
+ store.start_session_log(self.store_key, tx_obj.pk)
762
+ store.start_session_lock()
763
+ store.add_session_message(self.store_key, text_data)
764
+ await self._start_consumption_updates(tx_obj)
236
765
  reply_payload = {
237
766
  "transactionId": tx_obj.pk,
238
767
  "idTagInfo": {"status": "Accepted"},
@@ -241,7 +770,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
241
770
  reply_payload = {"idTagInfo": {"status": "Invalid"}}
242
771
  elif action == "StopTransaction":
243
772
  tx_id = payload.get("transactionId")
244
- tx_obj = store.transactions.pop(self.charger_id, None)
773
+ tx_obj = store.transactions.pop(self.store_key, None)
245
774
  if not tx_obj and tx_id is not None:
246
775
  tx_obj = await database_sync_to_async(
247
776
  Transaction.objects.filter(pk=tx_id, charger=self.charger).first
@@ -251,17 +780,62 @@ class CSMSConsumer(AsyncWebsocketConsumer):
251
780
  pk=tx_id,
252
781
  charger=self.charger,
253
782
  start_time=timezone.now(),
254
- meter_start=payload.get("meterStart") or payload.get("meterStop"),
783
+ meter_start=payload.get("meterStart")
784
+ or payload.get("meterStop"),
255
785
  vin=(payload.get("vin") or ""),
256
786
  )
257
787
  if tx_obj:
258
788
  tx_obj.meter_stop = payload.get("meterStop")
259
789
  tx_obj.stop_time = timezone.now()
260
790
  await database_sync_to_async(tx_obj.save)()
791
+ await self._update_consumption_message(tx_obj.pk)
792
+ await self._cancel_consumption_message()
261
793
  reply_payload = {"idTagInfo": {"status": "Accepted"}}
262
- store.end_session_log(self.charger_id)
794
+ store.end_session_log(self.store_key)
795
+ store.stop_session_lock()
796
+ elif action == "FirmwareStatusNotification":
797
+ status_raw = payload.get("status")
798
+ status = str(status_raw or "").strip()
799
+ info_value = payload.get("statusInfo")
800
+ if not isinstance(info_value, str):
801
+ info_value = payload.get("info")
802
+ status_info = str(info_value or "").strip()
803
+ timestamp_raw = payload.get("timestamp")
804
+ timestamp_value = None
805
+ if timestamp_raw:
806
+ timestamp_value = parse_datetime(str(timestamp_raw))
807
+ if timestamp_value and timezone.is_naive(timestamp_value):
808
+ timestamp_value = timezone.make_aware(
809
+ timestamp_value, timezone.get_current_timezone()
810
+ )
811
+ if timestamp_value is None:
812
+ timestamp_value = timezone.now()
813
+ await self._update_firmware_state(
814
+ status, status_info, timestamp_value
815
+ )
816
+ store.add_log(
817
+ self.store_key,
818
+ "FirmwareStatusNotification: "
819
+ + json.dumps(payload, separators=(",", ":")),
820
+ log_type="charger",
821
+ )
822
+ if (
823
+ self.aggregate_charger
824
+ and self.aggregate_charger.connector_id is None
825
+ ):
826
+ aggregate_key = store.identity_key(
827
+ self.charger_id, self.aggregate_charger.connector_id
828
+ )
829
+ if aggregate_key != self.store_key:
830
+ store.add_log(
831
+ aggregate_key,
832
+ "FirmwareStatusNotification: "
833
+ + json.dumps(payload, separators=(",", ":")),
834
+ log_type="charger",
835
+ )
836
+ reply_payload = {}
263
837
  response = [3, msg_id, reply_payload]
264
838
  await self.send(json.dumps(response))
265
839
  store.add_log(
266
- self.charger_id, f"< {json.dumps(response)}", log_type="charger"
840
+ self.store_key, f"< {json.dumps(response)}", log_type="charger"
267
841
  )