arthexis 0.1.7__py3-none-any.whl → 0.1.9__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 (82) hide show
  1. arthexis-0.1.9.dist-info/METADATA +168 -0
  2. arthexis-0.1.9.dist-info/RECORD +92 -0
  3. arthexis-0.1.9.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 +134 -16
  10. config/urls.py +71 -3
  11. core/admin.py +1331 -165
  12. core/admin_history.py +50 -0
  13. core/admindocs.py +151 -0
  14. core/apps.py +158 -3
  15. core/backends.py +46 -4
  16. core/entity.py +62 -48
  17. core/fields.py +6 -1
  18. core/github_helper.py +25 -0
  19. core/github_issues.py +172 -0
  20. core/lcd_screen.py +1 -0
  21. core/liveupdate.py +25 -0
  22. core/log_paths.py +100 -0
  23. core/mailer.py +83 -0
  24. core/middleware.py +57 -0
  25. core/models.py +1136 -259
  26. core/notifications.py +11 -1
  27. core/public_wifi.py +227 -0
  28. core/release.py +27 -20
  29. core/sigil_builder.py +131 -0
  30. core/sigil_context.py +20 -0
  31. core/sigil_resolver.py +284 -0
  32. core/system.py +129 -10
  33. core/tasks.py +118 -19
  34. core/test_system_info.py +22 -0
  35. core/tests.py +445 -58
  36. core/tests_liveupdate.py +17 -0
  37. core/urls.py +2 -2
  38. core/user_data.py +329 -167
  39. core/views.py +383 -57
  40. core/widgets.py +51 -0
  41. core/workgroup_urls.py +17 -0
  42. core/workgroup_views.py +94 -0
  43. nodes/actions.py +0 -2
  44. nodes/admin.py +159 -284
  45. nodes/apps.py +9 -15
  46. nodes/backends.py +53 -0
  47. nodes/lcd.py +24 -10
  48. nodes/models.py +375 -178
  49. nodes/tasks.py +1 -5
  50. nodes/tests.py +524 -129
  51. nodes/utils.py +13 -2
  52. nodes/views.py +66 -23
  53. ocpp/admin.py +150 -61
  54. ocpp/apps.py +4 -3
  55. ocpp/consumers.py +432 -69
  56. ocpp/evcs.py +25 -8
  57. ocpp/models.py +408 -68
  58. ocpp/simulator.py +13 -6
  59. ocpp/store.py +258 -30
  60. ocpp/tasks.py +11 -7
  61. ocpp/test_export_import.py +8 -7
  62. ocpp/test_rfid.py +211 -16
  63. ocpp/tests.py +1198 -135
  64. ocpp/transactions_io.py +68 -22
  65. ocpp/urls.py +35 -2
  66. ocpp/views.py +654 -101
  67. pages/admin.py +173 -13
  68. pages/checks.py +0 -1
  69. pages/context_processors.py +19 -6
  70. pages/middleware.py +153 -0
  71. pages/models.py +37 -9
  72. pages/tests.py +759 -40
  73. pages/urls.py +3 -0
  74. pages/utils.py +0 -1
  75. pages/views.py +576 -25
  76. arthexis-0.1.7.dist-info/METADATA +0 -126
  77. arthexis-0.1.7.dist-info/RECORD +0 -77
  78. arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
  79. config/workgroup_app.py +0 -7
  80. core/checks.py +0 -29
  81. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
  82. {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/top_level.txt +0 -0
ocpp/consumers.py CHANGED
@@ -1,9 +1,9 @@
1
- import asyncio
2
1
  import json
3
2
  import base64
4
3
  from datetime import datetime
5
4
  from django.utils import timezone
6
- from core.models import EnergyAccount
5
+ from core.models import EnergyAccount, RFID as CoreRFID
6
+ from nodes.models import NetMessage
7
7
 
8
8
  from channels.generic.websocket import AsyncWebsocketConsumer
9
9
  from channels.db import database_sync_to_async
@@ -13,7 +13,7 @@ from config.offline import requires_network
13
13
  from . import store
14
14
  from decimal import Decimal
15
15
  from django.utils.dateparse import parse_datetime
16
- from .models import Transaction, Charger, MeterReading
16
+ from .models import Transaction, Charger, MeterValue
17
17
 
18
18
 
19
19
  class SinkConsumer(AsyncWebsocketConsumer):
@@ -23,7 +23,9 @@ class SinkConsumer(AsyncWebsocketConsumer):
23
23
  async def connect(self) -> None:
24
24
  await self.accept()
25
25
 
26
- async def receive(self, text_data: str | None = None, bytes_data: bytes | None = None) -> None:
26
+ async def receive(
27
+ self, text_data: str | None = None, bytes_data: bytes | None = None
28
+ ) -> None:
27
29
  if text_data is None:
28
30
  return
29
31
  try:
@@ -40,34 +42,45 @@ class CSMSConsumer(AsyncWebsocketConsumer):
40
42
  @requires_network
41
43
  async def connect(self):
42
44
  self.charger_id = self.scope["url_route"]["kwargs"].get("cid", "")
45
+ self.connector_value: int | None = None
46
+ self.store_key = store.pending_key(self.charger_id)
47
+ self.aggregate_charger: Charger | None = None
43
48
  subprotocol = None
44
49
  offered = self.scope.get("subprotocols", [])
45
50
  if "ocpp1.6" in offered:
46
51
  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)
52
+ # Close any pending connection for this charger so reconnections do
53
+ # not leak stale consumers when the connector id has not been
54
+ # negotiated yet.
55
+ existing = store.connections.get(self.store_key)
50
56
  if existing is not None:
51
57
  await existing.close()
52
58
  await self.accept(subprotocol=subprotocol)
53
59
  store.add_log(
54
- self.charger_id,
60
+ self.store_key,
55
61
  f"Connected (subprotocol={subprotocol or 'none'})",
56
62
  log_type="charger",
57
63
  )
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
64
+ store.connections[self.store_key] = self
65
+ store.logs["charger"].setdefault(self.store_key, [])
66
+ self.charger, created = await database_sync_to_async(
67
+ Charger.objects.get_or_create
62
68
  )(
63
69
  charger_id=self.charger_id,
70
+ connector_id=None,
64
71
  defaults={"last_path": self.scope.get("path", "")},
65
72
  )
73
+ self.aggregate_charger = self.charger
66
74
  location_name = await sync_to_async(
67
75
  lambda: self.charger.location.name if self.charger.location else ""
68
76
  )()
77
+ friendly_name = location_name or self.charger_id
78
+ store.register_log_name(self.store_key, friendly_name, log_type="charger")
79
+ store.register_log_name(self.charger_id, friendly_name, log_type="charger")
69
80
  store.register_log_name(
70
- self.charger_id, location_name or self.charger_id, log_type="charger"
81
+ store.identity_key(self.charger_id, None),
82
+ friendly_name,
83
+ log_type="charger",
71
84
  )
72
85
 
73
86
  async def _get_account(self, id_tag: str) -> EnergyAccount | None:
@@ -80,16 +93,95 @@ class CSMSConsumer(AsyncWebsocketConsumer):
80
93
  ).first
81
94
  )()
82
95
 
96
+ async def _assign_connector(self, connector: int | str | None) -> None:
97
+ """Ensure ``self.charger`` matches the provided connector id."""
98
+ if connector is None:
99
+ return
100
+ try:
101
+ connector_value = int(connector)
102
+ except (TypeError, ValueError):
103
+ return
104
+ if (
105
+ self.connector_value == connector_value
106
+ and self.charger.connector_id == connector_value
107
+ ):
108
+ return
109
+ if (
110
+ not self.aggregate_charger
111
+ or self.aggregate_charger.connector_id is not None
112
+ ):
113
+ self.aggregate_charger = await database_sync_to_async(
114
+ Charger.objects.get_or_create
115
+ )(
116
+ charger_id=self.charger_id,
117
+ connector_id=None,
118
+ defaults={"last_path": self.scope.get("path", "")},
119
+ )[
120
+ 0
121
+ ]
122
+ existing = await database_sync_to_async(
123
+ Charger.objects.filter(
124
+ charger_id=self.charger_id, connector_id=connector_value
125
+ ).first
126
+ )()
127
+ if existing:
128
+ self.charger = existing
129
+ else:
130
+
131
+ def _create_connector():
132
+ charger, _ = Charger.objects.get_or_create(
133
+ charger_id=self.charger_id,
134
+ connector_id=connector_value,
135
+ defaults={"last_path": self.scope.get("path", "")},
136
+ )
137
+ if self.scope.get("path") and charger.last_path != self.scope.get(
138
+ "path"
139
+ ):
140
+ charger.last_path = self.scope.get("path")
141
+ charger.save(update_fields=["last_path"])
142
+ return charger
143
+
144
+ self.charger = await database_sync_to_async(_create_connector)()
145
+ previous_key = self.store_key
146
+ new_key = store.identity_key(self.charger_id, connector_value)
147
+ if previous_key != new_key:
148
+ existing_consumer = store.connections.get(new_key)
149
+ if existing_consumer is not None and existing_consumer is not self:
150
+ await existing_consumer.close()
151
+ store.reassign_identity(previous_key, new_key)
152
+ store.connections[new_key] = self
153
+ store.logs["charger"].setdefault(new_key, [])
154
+ connector_name = await sync_to_async(
155
+ lambda: self.charger.name or self.charger.charger_id
156
+ )()
157
+ store.register_log_name(new_key, connector_name, log_type="charger")
158
+ aggregate_name = ""
159
+ if self.aggregate_charger:
160
+ aggregate_name = await sync_to_async(
161
+ lambda: self.aggregate_charger.name or self.aggregate_charger.charger_id
162
+ )()
163
+ store.register_log_name(
164
+ store.identity_key(self.charger_id, None),
165
+ aggregate_name or self.charger_id,
166
+ log_type="charger",
167
+ )
168
+ self.store_key = new_key
169
+ self.connector_value = connector_value
170
+
83
171
  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")
172
+ """Parse a MeterValues payload into MeterValue rows."""
173
+ connector_raw = payload.get("connectorId")
174
+ connector_value = None
175
+ if connector_raw is not None:
176
+ try:
177
+ connector_value = int(connector_raw)
178
+ except (TypeError, ValueError):
179
+ connector_value = None
180
+ await self._assign_connector(connector_value)
86
181
  tx_id = payload.get("transactionId")
87
182
  tx_obj = None
88
183
  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)
184
+ tx_obj = store.transactions.get(self.store_key)
93
185
  if not tx_obj or tx_obj.pk != int(tx_id):
94
186
  tx_obj = await database_sync_to_async(
95
187
  Transaction.objects.filter(pk=tx_id, charger=self.charger).first
@@ -98,57 +190,87 @@ class CSMSConsumer(AsyncWebsocketConsumer):
98
190
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
99
191
  pk=tx_id, charger=self.charger, start_time=timezone.now()
100
192
  )
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
193
+ store.start_session_log(self.store_key, tx_obj.pk)
194
+ store.add_session_message(self.store_key, raw_message)
195
+ store.transactions[self.store_key] = tx_obj
104
196
  else:
105
- tx_obj = store.transactions.get(self.charger_id)
197
+ tx_obj = store.transactions.get(self.store_key)
106
198
 
107
199
  readings = []
108
- start_updated = False
200
+ updated_fields: set[str] = set()
109
201
  temperature = None
110
202
  temp_unit = ""
111
203
  for mv in payload.get("meterValue", []):
112
204
  ts = parse_datetime(mv.get("timestamp"))
205
+ values: dict[str, Decimal] = {}
206
+ context = ""
113
207
  for sv in mv.get("sampledValue", []):
114
208
  try:
115
209
  val = Decimal(str(sv.get("value")))
116
210
  except Exception:
117
211
  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
212
+ context = sv.get("context", context or "")
129
213
  measurand = sv.get("measurand", "")
130
214
  unit = sv.get("unit", "")
131
- if measurand == "Temperature":
215
+ field = None
216
+ if measurand in ("", "Energy.Active.Import.Register"):
217
+ field = "energy"
218
+ if unit == "Wh":
219
+ val = val / Decimal("1000")
220
+ elif measurand == "Voltage":
221
+ field = "voltage"
222
+ elif measurand == "Current.Import":
223
+ field = "current_import"
224
+ elif measurand == "Current.Offered":
225
+ field = "current_offered"
226
+ elif measurand == "Temperature":
227
+ field = "temperature"
132
228
  temperature = val
133
229
  temp_unit = unit
230
+ elif measurand == "SoC":
231
+ field = "soc"
232
+ if field:
233
+ if tx_obj and context in ("Transaction.Begin", "Transaction.End"):
234
+ suffix = "start" if context == "Transaction.Begin" else "stop"
235
+ if field == "energy":
236
+ mult = 1000 if unit in ("kW", "kWh") else 1
237
+ setattr(tx_obj, f"meter_{suffix}", int(val * mult))
238
+ updated_fields.add(f"meter_{suffix}")
239
+ else:
240
+ setattr(tx_obj, f"{field}_{suffix}", val)
241
+ updated_fields.add(f"{field}_{suffix}")
242
+ else:
243
+ values[field] = val
244
+ if tx_obj and field == "energy" and tx_obj.meter_start is None:
245
+ mult = 1000 if unit in ("kW", "kWh") else 1
246
+ try:
247
+ tx_obj.meter_start = int(val * mult)
248
+ except (TypeError, ValueError):
249
+ pass
250
+ else:
251
+ updated_fields.add("meter_start")
252
+ if values and context not in ("Transaction.Begin", "Transaction.End"):
134
253
  readings.append(
135
- MeterReading(
254
+ MeterValue(
136
255
  charger=self.charger,
137
- connector_id=connector,
256
+ connector_id=connector_value,
138
257
  transaction=tx_obj,
139
258
  timestamp=ts,
140
- measurand=measurand,
141
- value=val,
142
- unit=unit,
259
+ context=context,
260
+ **values,
143
261
  )
144
262
  )
145
263
  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"])
264
+ await database_sync_to_async(MeterValue.objects.bulk_create)(readings)
265
+ if tx_obj and updated_fields:
266
+ await database_sync_to_async(tx_obj.save)(
267
+ update_fields=list(updated_fields)
268
+ )
269
+ if connector_value is not None and not self.charger.connector_id:
270
+ self.charger.connector_id = connector_value
271
+ await database_sync_to_async(self.charger.save)(
272
+ update_fields=["connector_id"]
273
+ )
152
274
  if temperature is not None:
153
275
  self.charger.temperature = temperature
154
276
  self.charger.temperature_unit = temp_unit
@@ -156,12 +278,84 @@ class CSMSConsumer(AsyncWebsocketConsumer):
156
278
  update_fields=["temperature", "temperature_unit"]
157
279
  )
158
280
 
281
+ async def _update_firmware_state(
282
+ self, status: str, status_info: str, timestamp: datetime | None
283
+ ) -> None:
284
+ """Persist firmware status fields for the active charger identities."""
285
+
286
+ targets: list[Charger] = []
287
+ seen_ids: set[int] = set()
288
+ for charger in (self.charger, self.aggregate_charger):
289
+ if not charger or charger.pk is None:
290
+ continue
291
+ if charger.pk in seen_ids:
292
+ continue
293
+ targets.append(charger)
294
+ seen_ids.add(charger.pk)
295
+
296
+ if not targets:
297
+ return
298
+
299
+ def _persist(ids: list[int]) -> None:
300
+ Charger.objects.filter(pk__in=ids).update(
301
+ firmware_status=status,
302
+ firmware_status_info=status_info,
303
+ firmware_timestamp=timestamp,
304
+ )
305
+
306
+ await database_sync_to_async(_persist)([target.pk for target in targets])
307
+ for target in targets:
308
+ target.firmware_status = status
309
+ target.firmware_status_info = status_info
310
+ target.firmware_timestamp = timestamp
311
+
312
+ async def _broadcast_charging_started(self) -> None:
313
+ """Send a network message announcing a charging session."""
314
+
315
+ def _message_payload() -> dict[str, str] | None:
316
+ charger = self.charger
317
+ aggregate = self.aggregate_charger
318
+ if not charger:
319
+ return None
320
+ location_name = ""
321
+ if charger.location_id:
322
+ location_name = charger.location.name
323
+ elif aggregate and aggregate.location_id:
324
+ location_name = aggregate.location.name
325
+ cid_value = (
326
+ charger.connector_slug
327
+ if charger.connector_id is not None
328
+ else Charger.AGGREGATE_CONNECTOR_SLUG
329
+ )
330
+ return {
331
+ "location": location_name,
332
+ "sn": charger.charger_id,
333
+ "cid": str(cid_value),
334
+ }
335
+
336
+ payload = await database_sync_to_async(_message_payload)()
337
+ if not payload:
338
+ return
339
+ try:
340
+ await database_sync_to_async(NetMessage.broadcast)(
341
+ subject="charging-started",
342
+ body=json.dumps(payload, separators=(",", ":")),
343
+ )
344
+ except Exception as exc: # pragma: no cover - logging of unexpected errors
345
+ store.add_log(
346
+ self.store_key,
347
+ f"Failed to broadcast charging start: {exc}",
348
+ log_type="charger",
349
+ )
350
+
159
351
  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
- )
352
+ store.connections.pop(self.store_key, None)
353
+ pending_key = store.pending_key(self.charger_id)
354
+ if self.store_key != pending_key:
355
+ store.connections.pop(pending_key, None)
356
+ store.end_session_log(self.store_key)
357
+ store.stop_session_lock()
358
+ store.add_log(self.store_key, f"Closed (code={close_code})", log_type="charger")
165
359
 
166
360
  async def receive(self, text_data=None, bytes_data=None):
167
361
  raw = text_data
@@ -169,8 +363,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
169
363
  raw = base64.b64encode(bytes_data).decode("ascii")
170
364
  if raw is None:
171
365
  return
172
- store.add_log(self.charger_id, raw, log_type="charger")
173
- store.add_session_message(self.charger_id, raw)
366
+ store.add_log(self.store_key, raw, log_type="charger")
367
+ store.add_session_message(self.store_key, raw)
174
368
  try:
175
369
  msg = json.loads(raw)
176
370
  except json.JSONDecodeError:
@@ -179,6 +373,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
179
373
  msg_id, action = msg[1], msg[2]
180
374
  payload = msg[3] if len(msg) > 3 else {}
181
375
  reply_payload = {}
376
+ await self._assign_connector(payload.get("connectorId"))
182
377
  if action == "BootNotification":
183
378
  reply_payload = {
184
379
  "currentTime": datetime.utcnow().isoformat() + "Z",
@@ -186,20 +381,76 @@ class CSMSConsumer(AsyncWebsocketConsumer):
186
381
  "status": "Accepted",
187
382
  }
188
383
  elif action == "Heartbeat":
189
- reply_payload = {
190
- "currentTime": datetime.utcnow().isoformat() + "Z"
191
- }
384
+ reply_payload = {"currentTime": datetime.utcnow().isoformat() + "Z"}
192
385
  now = timezone.now()
193
386
  self.charger.last_heartbeat = now
194
387
  await database_sync_to_async(
195
- Charger.objects.filter(charger_id=self.charger_id).update
388
+ Charger.objects.filter(pk=self.charger.pk).update
196
389
  )(last_heartbeat=now)
390
+ elif action == "StatusNotification":
391
+ await self._assign_connector(payload.get("connectorId"))
392
+ status = (payload.get("status") or "").strip()
393
+ error_code = (payload.get("errorCode") or "").strip()
394
+ vendor_info = {
395
+ key: value
396
+ for key, value in (
397
+ ("info", payload.get("info")),
398
+ ("vendorId", payload.get("vendorId")),
399
+ )
400
+ if value
401
+ }
402
+ vendor_value = vendor_info or None
403
+ timestamp_raw = payload.get("timestamp")
404
+ status_timestamp = (
405
+ parse_datetime(timestamp_raw) if timestamp_raw else None
406
+ )
407
+ if status_timestamp is None:
408
+ status_timestamp = timezone.now()
409
+ elif timezone.is_naive(status_timestamp):
410
+ status_timestamp = timezone.make_aware(status_timestamp)
411
+ update_kwargs = {
412
+ "last_status": status,
413
+ "last_error_code": error_code,
414
+ "last_status_vendor_info": vendor_value,
415
+ "last_status_timestamp": status_timestamp,
416
+ }
417
+
418
+ def _update_instance(instance: Charger | None) -> None:
419
+ if not instance:
420
+ return
421
+ instance.last_status = status
422
+ instance.last_error_code = error_code
423
+ instance.last_status_vendor_info = vendor_value
424
+ instance.last_status_timestamp = status_timestamp
425
+
426
+ await database_sync_to_async(
427
+ Charger.objects.filter(
428
+ charger_id=self.charger_id, connector_id=None
429
+ ).update
430
+ )(**update_kwargs)
431
+ connector_value = self.connector_value
432
+ if connector_value is not None:
433
+ await database_sync_to_async(
434
+ Charger.objects.filter(
435
+ charger_id=self.charger_id,
436
+ connector_id=connector_value,
437
+ ).update
438
+ )(**update_kwargs)
439
+ _update_instance(self.aggregate_charger)
440
+ _update_instance(self.charger)
441
+ store.add_log(
442
+ self.store_key,
443
+ f"StatusNotification processed: {json.dumps(payload, sort_keys=True)}",
444
+ log_type="charger",
445
+ )
446
+ reply_payload = {}
197
447
  elif action == "Authorize":
198
448
  account = await self._get_account(payload.get("idTag"))
199
449
  if self.charger.require_rfid:
200
450
  status = (
201
451
  "Accepted"
202
- if account and await database_sync_to_async(account.can_authorize)()
452
+ if account
453
+ and await database_sync_to_async(account.can_authorize)()
203
454
  else "Invalid"
204
455
  )
205
456
  else:
@@ -209,11 +460,77 @@ class CSMSConsumer(AsyncWebsocketConsumer):
209
460
  await self._store_meter_values(payload, text_data)
210
461
  self.charger.last_meter_values = payload
211
462
  await database_sync_to_async(
212
- Charger.objects.filter(charger_id=self.charger_id).update
463
+ Charger.objects.filter(pk=self.charger.pk).update
213
464
  )(last_meter_values=payload)
214
465
  reply_payload = {}
466
+ elif action == "DiagnosticsStatusNotification":
467
+ status_value = payload.get("status")
468
+ location_value = (
469
+ payload.get("uploadLocation")
470
+ or payload.get("location")
471
+ or payload.get("uri")
472
+ )
473
+ timestamp_value = payload.get("timestamp")
474
+ diagnostics_timestamp = None
475
+ if timestamp_value:
476
+ diagnostics_timestamp = parse_datetime(timestamp_value)
477
+ if diagnostics_timestamp and timezone.is_naive(
478
+ diagnostics_timestamp
479
+ ):
480
+ diagnostics_timestamp = timezone.make_aware(
481
+ diagnostics_timestamp, timezone=timezone.utc
482
+ )
483
+
484
+ updates = {
485
+ "diagnostics_status": status_value or None,
486
+ "diagnostics_timestamp": diagnostics_timestamp,
487
+ "diagnostics_location": location_value or None,
488
+ }
489
+
490
+ def _persist_diagnostics():
491
+ targets: list[Charger] = []
492
+ if self.charger:
493
+ targets.append(self.charger)
494
+ aggregate = self.aggregate_charger
495
+ if (
496
+ aggregate
497
+ and not any(
498
+ target.pk == aggregate.pk for target in targets if target.pk
499
+ )
500
+ ):
501
+ targets.append(aggregate)
502
+ for target in targets:
503
+ for field, value in updates.items():
504
+ setattr(target, field, value)
505
+ if target.pk:
506
+ Charger.objects.filter(pk=target.pk).update(**updates)
507
+
508
+ await database_sync_to_async(_persist_diagnostics)()
509
+
510
+ status_label = updates["diagnostics_status"] or "unknown"
511
+ log_message = "DiagnosticsStatusNotification: status=%s" % (
512
+ status_label,
513
+ )
514
+ if updates["diagnostics_timestamp"]:
515
+ log_message += ", timestamp=%s" % (
516
+ updates["diagnostics_timestamp"].isoformat()
517
+ )
518
+ if updates["diagnostics_location"]:
519
+ log_message += ", location=%s" % updates["diagnostics_location"]
520
+ store.add_log(self.store_key, log_message, log_type="charger")
521
+ if self.aggregate_charger and self.aggregate_charger.connector_id is None:
522
+ aggregate_key = store.identity_key(self.charger_id, None)
523
+ if aggregate_key != self.store_key:
524
+ store.add_log(aggregate_key, log_message, log_type="charger")
525
+ reply_payload = {}
215
526
  elif action == "StartTransaction":
216
- account = await self._get_account(payload.get("idTag"))
527
+ id_tag = payload.get("idTag")
528
+ account = await self._get_account(id_tag)
529
+ if id_tag:
530
+ await database_sync_to_async(CoreRFID.objects.get_or_create)(
531
+ rfid=id_tag.upper()
532
+ )
533
+ await self._assign_connector(payload.get("connectorId"))
217
534
  if self.charger.require_rfid:
218
535
  authorized = (
219
536
  account is not None
@@ -225,14 +542,17 @@ class CSMSConsumer(AsyncWebsocketConsumer):
225
542
  tx_obj = await database_sync_to_async(Transaction.objects.create)(
226
543
  charger=self.charger,
227
544
  account=account,
228
- rfid=(payload.get("idTag") or ""),
545
+ rfid=(id_tag or ""),
229
546
  vin=(payload.get("vin") or ""),
547
+ connector_id=payload.get("connectorId"),
230
548
  meter_start=payload.get("meterStart"),
231
549
  start_time=timezone.now(),
232
550
  )
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)
551
+ store.transactions[self.store_key] = tx_obj
552
+ store.start_session_log(self.store_key, tx_obj.pk)
553
+ store.start_session_lock()
554
+ store.add_session_message(self.store_key, text_data)
555
+ await self._broadcast_charging_started()
236
556
  reply_payload = {
237
557
  "transactionId": tx_obj.pk,
238
558
  "idTagInfo": {"status": "Accepted"},
@@ -241,7 +561,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
241
561
  reply_payload = {"idTagInfo": {"status": "Invalid"}}
242
562
  elif action == "StopTransaction":
243
563
  tx_id = payload.get("transactionId")
244
- tx_obj = store.transactions.pop(self.charger_id, None)
564
+ tx_obj = store.transactions.pop(self.store_key, None)
245
565
  if not tx_obj and tx_id is not None:
246
566
  tx_obj = await database_sync_to_async(
247
567
  Transaction.objects.filter(pk=tx_id, charger=self.charger).first
@@ -251,7 +571,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
251
571
  pk=tx_id,
252
572
  charger=self.charger,
253
573
  start_time=timezone.now(),
254
- meter_start=payload.get("meterStart") or payload.get("meterStop"),
574
+ meter_start=payload.get("meterStart")
575
+ or payload.get("meterStop"),
255
576
  vin=(payload.get("vin") or ""),
256
577
  )
257
578
  if tx_obj:
@@ -259,9 +580,51 @@ class CSMSConsumer(AsyncWebsocketConsumer):
259
580
  tx_obj.stop_time = timezone.now()
260
581
  await database_sync_to_async(tx_obj.save)()
261
582
  reply_payload = {"idTagInfo": {"status": "Accepted"}}
262
- store.end_session_log(self.charger_id)
583
+ store.end_session_log(self.store_key)
584
+ store.stop_session_lock()
585
+ elif action == "FirmwareStatusNotification":
586
+ status_raw = payload.get("status")
587
+ status = str(status_raw or "").strip()
588
+ info_value = payload.get("statusInfo")
589
+ if not isinstance(info_value, str):
590
+ info_value = payload.get("info")
591
+ status_info = str(info_value or "").strip()
592
+ timestamp_raw = payload.get("timestamp")
593
+ timestamp_value = None
594
+ if timestamp_raw:
595
+ timestamp_value = parse_datetime(str(timestamp_raw))
596
+ if timestamp_value and timezone.is_naive(timestamp_value):
597
+ timestamp_value = timezone.make_aware(
598
+ timestamp_value, timezone.get_current_timezone()
599
+ )
600
+ if timestamp_value is None:
601
+ timestamp_value = timezone.now()
602
+ await self._update_firmware_state(
603
+ status, status_info, timestamp_value
604
+ )
605
+ store.add_log(
606
+ self.store_key,
607
+ "FirmwareStatusNotification: "
608
+ + json.dumps(payload, separators=(",", ":")),
609
+ log_type="charger",
610
+ )
611
+ if (
612
+ self.aggregate_charger
613
+ and self.aggregate_charger.connector_id is None
614
+ ):
615
+ aggregate_key = store.identity_key(
616
+ self.charger_id, self.aggregate_charger.connector_id
617
+ )
618
+ if aggregate_key != self.store_key:
619
+ store.add_log(
620
+ aggregate_key,
621
+ "FirmwareStatusNotification: "
622
+ + json.dumps(payload, separators=(",", ":")),
623
+ log_type="charger",
624
+ )
625
+ reply_payload = {}
263
626
  response = [3, msg_id, reply_payload]
264
627
  await self.send(json.dumps(response))
265
628
  store.add_log(
266
- self.charger_id, f"< {json.dumps(response)}", log_type="charger"
629
+ self.store_key, f"< {json.dumps(response)}", log_type="charger"
267
630
  )