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.
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/METADATA +87 -6
- arthexis-0.1.10.dist-info/RECORD +95 -0
- arthexis-0.1.10.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 +352 -37
- config/urls.py +71 -6
- core/admin.py +1601 -200
- core/admin_history.py +50 -0
- core/admindocs.py +108 -1
- core/apps.py +161 -3
- core/auto_upgrade.py +57 -0
- core/backends.py +123 -7
- core/entity.py +62 -48
- core/fields.py +98 -0
- 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 +1279 -267
- core/notifications.py +11 -1
- core/public_wifi.py +227 -0
- core/reference_utils.py +97 -0
- core/release.py +27 -20
- core/sigil_builder.py +144 -0
- core/sigil_context.py +20 -0
- core/sigil_resolver.py +284 -0
- core/system.py +162 -29
- core/tasks.py +269 -27
- core/test_system_info.py +59 -1
- core/tests.py +644 -73
- core/tests_liveupdate.py +17 -0
- core/urls.py +2 -2
- core/user_data.py +425 -168
- core/views.py +627 -59
- core/widgets.py +51 -0
- core/workgroup_urls.py +7 -3
- core/workgroup_views.py +43 -6
- nodes/actions.py +0 -2
- nodes/admin.py +168 -285
- nodes/apps.py +9 -15
- nodes/backends.py +145 -0
- nodes/lcd.py +24 -10
- nodes/models.py +579 -179
- nodes/tasks.py +1 -5
- nodes/tests.py +894 -130
- nodes/utils.py +13 -2
- nodes/views.py +204 -28
- ocpp/admin.py +212 -63
- ocpp/apps.py +1 -1
- ocpp/consumers.py +642 -68
- ocpp/evcs.py +30 -10
- ocpp/models.py +452 -70
- ocpp/simulator.py +75 -11
- ocpp/store.py +288 -30
- ocpp/tasks.py +11 -7
- ocpp/test_export_import.py +8 -7
- ocpp/test_rfid.py +211 -16
- ocpp/tests.py +1576 -137
- ocpp/transactions_io.py +68 -22
- ocpp/urls.py +35 -2
- ocpp/views.py +701 -123
- pages/admin.py +173 -13
- pages/checks.py +0 -1
- pages/context_processors.py +39 -6
- pages/forms.py +131 -0
- pages/middleware.py +153 -0
- pages/models.py +37 -9
- pages/tests.py +1182 -42
- pages/urls.py +4 -0
- pages/utils.py +0 -1
- pages/views.py +844 -51
- arthexis-0.1.8.dist-info/RECORD +0 -80
- arthexis-0.1.8.dist-info/licenses/LICENSE +0 -21
- config/workgroup_app.py +0 -7
- core/checks.py +0 -29
- {arthexis-0.1.8.dist-info → arthexis-0.1.10.dist-info}/WHEEL +0 -0
- {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,
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
161
|
+
self.store_key,
|
|
55
162
|
f"Connected (subprotocol={subprotocol or 'none'})",
|
|
56
163
|
log_type="charger",
|
|
57
164
|
)
|
|
58
|
-
store.connections[self.
|
|
59
|
-
store.logs["charger"].setdefault(self.
|
|
60
|
-
self.charger,
|
|
61
|
-
Charger.objects.
|
|
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,
|
|
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
|
|
85
|
-
|
|
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
|
-
|
|
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.
|
|
102
|
-
store.add_session_message(self.
|
|
103
|
-
store.transactions[self.
|
|
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.
|
|
342
|
+
tx_obj = store.transactions.get(self.store_key)
|
|
106
343
|
|
|
107
344
|
readings = []
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
+
MeterValue(
|
|
136
400
|
charger=self.charger,
|
|
137
|
-
connector_id=
|
|
401
|
+
connector_id=connector_value,
|
|
138
402
|
transaction=tx_obj,
|
|
139
403
|
timestamp=ts,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
unit=unit,
|
|
404
|
+
context=context,
|
|
405
|
+
**values,
|
|
143
406
|
)
|
|
144
407
|
)
|
|
145
408
|
if readings:
|
|
146
|
-
await database_sync_to_async(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
self.charger_id,
|
|
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.
|
|
173
|
-
store.add_session_message(self.
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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=(
|
|
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.
|
|
234
|
-
store.start_session_log(self.
|
|
235
|
-
store.
|
|
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.
|
|
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")
|
|
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.
|
|
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.
|
|
840
|
+
self.store_key, f"< {json.dumps(response)}", log_type="charger"
|
|
267
841
|
)
|