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.
- arthexis-0.1.9.dist-info/METADATA +168 -0
- arthexis-0.1.9.dist-info/RECORD +92 -0
- arthexis-0.1.9.dist-info/licenses/LICENSE +674 -0
- config/__init__.py +0 -1
- config/auth_app.py +0 -1
- config/celery.py +1 -2
- config/context_processors.py +1 -1
- config/offline.py +2 -0
- config/settings.py +134 -16
- config/urls.py +71 -3
- core/admin.py +1331 -165
- core/admin_history.py +50 -0
- core/admindocs.py +151 -0
- core/apps.py +158 -3
- core/backends.py +46 -4
- core/entity.py +62 -48
- core/fields.py +6 -1
- core/github_helper.py +25 -0
- core/github_issues.py +172 -0
- core/lcd_screen.py +1 -0
- core/liveupdate.py +25 -0
- core/log_paths.py +100 -0
- core/mailer.py +83 -0
- core/middleware.py +57 -0
- core/models.py +1136 -259
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/release.py +27 -20
- core/sigil_builder.py +131 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +129 -10
- core/tasks.py +118 -19
- core/test_system_info.py +22 -0
- core/tests.py +445 -58
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +329 -167
- core/views.py +383 -57
- core/widgets.py +51 -0
- core/workgroup_urls.py +17 -0
- core/workgroup_views.py +94 -0
- nodes/actions.py +0 -2
- nodes/admin.py +159 -284
- nodes/apps.py +9 -15
- nodes/backends.py +53 -0
- nodes/lcd.py +24 -10
- nodes/models.py +375 -178
- nodes/tasks.py +1 -5
- nodes/tests.py +524 -129
- nodes/utils.py +13 -2
- nodes/views.py +66 -23
- ocpp/admin.py +150 -61
- ocpp/apps.py +4 -3
- ocpp/consumers.py +432 -69
- ocpp/evcs.py +25 -8
- ocpp/models.py +408 -68
- ocpp/simulator.py +13 -6
- ocpp/store.py +258 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1198 -135
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +654 -101
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +19 -6
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +759 -40
- pages/urls.py +3 -0
- pages/utils.py +0 -1
- pages/views.py +576 -25
- arthexis-0.1.7.dist-info/METADATA +0 -126
- arthexis-0.1.7.dist-info/RECORD +0 -77
- arthexis-0.1.7.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.7.dist-info → arthexis-0.1.9.dist-info}/WHEEL +0 -0
- {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,
|
|
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(
|
|
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
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
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.
|
|
60
|
+
self.store_key,
|
|
55
61
|
f"Connected (subprotocol={subprotocol or 'none'})",
|
|
56
62
|
log_type="charger",
|
|
57
63
|
)
|
|
58
|
-
store.connections[self.
|
|
59
|
-
store.logs["charger"].setdefault(self.
|
|
60
|
-
self.charger,
|
|
61
|
-
Charger.objects.
|
|
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,
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
102
|
-
store.add_session_message(self.
|
|
103
|
-
store.transactions[self.
|
|
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.
|
|
197
|
+
tx_obj = store.transactions.get(self.store_key)
|
|
106
198
|
|
|
107
199
|
readings = []
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
+
MeterValue(
|
|
136
255
|
charger=self.charger,
|
|
137
|
-
connector_id=
|
|
256
|
+
connector_id=connector_value,
|
|
138
257
|
transaction=tx_obj,
|
|
139
258
|
timestamp=ts,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
unit=unit,
|
|
259
|
+
context=context,
|
|
260
|
+
**values,
|
|
143
261
|
)
|
|
144
262
|
)
|
|
145
263
|
if readings:
|
|
146
|
-
await database_sync_to_async(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
161
|
-
store.
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
173
|
-
store.add_session_message(self.
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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=(
|
|
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.
|
|
234
|
-
store.start_session_log(self.
|
|
235
|
-
store.
|
|
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.
|
|
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")
|
|
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.
|
|
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.
|
|
629
|
+
self.store_key, f"< {json.dumps(response)}", log_type="charger"
|
|
267
630
|
)
|