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/simulator.py
CHANGED
|
@@ -3,6 +3,7 @@ import base64
|
|
|
3
3
|
import json
|
|
4
4
|
import random
|
|
5
5
|
import time
|
|
6
|
+
import uuid
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from typing import Optional
|
|
8
9
|
import threading
|
|
@@ -18,7 +19,7 @@ class SimulatorConfig:
|
|
|
18
19
|
"""Configuration for a simulated charge point."""
|
|
19
20
|
|
|
20
21
|
host: str = "127.0.0.1"
|
|
21
|
-
ws_port: int = 8000
|
|
22
|
+
ws_port: Optional[int] = 8000
|
|
22
23
|
rfid: str = "FFFFFFFF"
|
|
23
24
|
vin: str = ""
|
|
24
25
|
# WebSocket path for the charge point. Defaults to just the charger ID at the root.
|
|
@@ -31,6 +32,8 @@ class SimulatorConfig:
|
|
|
31
32
|
repeat: bool = False
|
|
32
33
|
username: Optional[str] = None
|
|
33
34
|
password: Optional[str] = None
|
|
35
|
+
serial_number: str = ""
|
|
36
|
+
connector_id: int = 1
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
class ChargePointSimulator:
|
|
@@ -40,14 +43,65 @@ class ChargePointSimulator:
|
|
|
40
43
|
self.config = config
|
|
41
44
|
self._thread: Optional[threading.Thread] = None
|
|
42
45
|
self._stop_event = threading.Event()
|
|
46
|
+
self._door_open_event = threading.Event()
|
|
43
47
|
self.status = "stopped"
|
|
44
48
|
self._connected = threading.Event()
|
|
45
49
|
self._connect_error = ""
|
|
46
50
|
|
|
51
|
+
def trigger_door_open(self) -> None:
|
|
52
|
+
"""Queue a DoorOpen status notification for the simulator."""
|
|
53
|
+
|
|
54
|
+
self._door_open_event.set()
|
|
55
|
+
|
|
56
|
+
async def _maybe_send_door_event(self, send, recv) -> None:
|
|
57
|
+
if not self._door_open_event.is_set():
|
|
58
|
+
return
|
|
59
|
+
self._door_open_event.clear()
|
|
60
|
+
cfg = self.config
|
|
61
|
+
store.add_log(
|
|
62
|
+
cfg.cp_path,
|
|
63
|
+
"Sending DoorOpen StatusNotification",
|
|
64
|
+
log_type="simulator",
|
|
65
|
+
)
|
|
66
|
+
event_id = uuid.uuid4().hex
|
|
67
|
+
await send(
|
|
68
|
+
json.dumps(
|
|
69
|
+
[
|
|
70
|
+
2,
|
|
71
|
+
f"door-open-{event_id}",
|
|
72
|
+
"StatusNotification",
|
|
73
|
+
{
|
|
74
|
+
"connectorId": cfg.connector_id,
|
|
75
|
+
"errorCode": "DoorOpen",
|
|
76
|
+
"status": "Faulted",
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
await recv()
|
|
82
|
+
await send(
|
|
83
|
+
json.dumps(
|
|
84
|
+
[
|
|
85
|
+
2,
|
|
86
|
+
f"door-closed-{event_id}",
|
|
87
|
+
"StatusNotification",
|
|
88
|
+
{
|
|
89
|
+
"connectorId": cfg.connector_id,
|
|
90
|
+
"errorCode": "NoError",
|
|
91
|
+
"status": "Available",
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
await recv()
|
|
97
|
+
|
|
47
98
|
@requires_network
|
|
48
99
|
async def _run_session(self) -> None:
|
|
49
100
|
cfg = self.config
|
|
50
|
-
|
|
101
|
+
if cfg.ws_port:
|
|
102
|
+
uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
|
|
103
|
+
else:
|
|
104
|
+
uri = f"ws://{cfg.host}/{cfg.cp_path}"
|
|
51
105
|
headers = {}
|
|
52
106
|
if cfg.username and cfg.password:
|
|
53
107
|
userpass = f"{cfg.username}:{cfg.password}"
|
|
@@ -113,6 +167,7 @@ class ChargePointSimulator:
|
|
|
113
167
|
{
|
|
114
168
|
"chargePointModel": "Simulator",
|
|
115
169
|
"chargePointVendor": "SimVendor",
|
|
170
|
+
"serialNumber": cfg.serial_number,
|
|
116
171
|
},
|
|
117
172
|
]
|
|
118
173
|
)
|
|
@@ -131,6 +186,7 @@ class ChargePointSimulator:
|
|
|
131
186
|
|
|
132
187
|
await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
|
|
133
188
|
await recv()
|
|
189
|
+
await self._maybe_send_door_event(send, recv)
|
|
134
190
|
if not self._connected.is_set():
|
|
135
191
|
self.status = "running"
|
|
136
192
|
self._connect_error = "accepted"
|
|
@@ -145,7 +201,7 @@ class ChargePointSimulator:
|
|
|
145
201
|
"status",
|
|
146
202
|
"StatusNotification",
|
|
147
203
|
{
|
|
148
|
-
"connectorId":
|
|
204
|
+
"connectorId": cfg.connector_id,
|
|
149
205
|
"errorCode": "NoError",
|
|
150
206
|
"status": "Available",
|
|
151
207
|
},
|
|
@@ -162,10 +218,12 @@ class ChargePointSimulator:
|
|
|
162
218
|
"meter",
|
|
163
219
|
"MeterValues",
|
|
164
220
|
{
|
|
165
|
-
"connectorId":
|
|
221
|
+
"connectorId": cfg.connector_id,
|
|
166
222
|
"meterValue": [
|
|
167
223
|
{
|
|
168
|
-
"timestamp": time.strftime(
|
|
224
|
+
"timestamp": time.strftime(
|
|
225
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
226
|
+
),
|
|
169
227
|
"sampledValue": [
|
|
170
228
|
{
|
|
171
229
|
"value": "0",
|
|
@@ -177,10 +235,11 @@ class ChargePointSimulator:
|
|
|
177
235
|
],
|
|
178
236
|
},
|
|
179
237
|
]
|
|
180
|
-
)
|
|
181
238
|
)
|
|
182
|
-
|
|
183
|
-
|
|
239
|
+
)
|
|
240
|
+
await recv()
|
|
241
|
+
await self._maybe_send_door_event(send, recv)
|
|
242
|
+
await asyncio.sleep(cfg.interval)
|
|
184
243
|
|
|
185
244
|
meter_start = random.randint(1000, 2000)
|
|
186
245
|
await send(
|
|
@@ -190,7 +249,7 @@ class ChargePointSimulator:
|
|
|
190
249
|
"start",
|
|
191
250
|
"StartTransaction",
|
|
192
251
|
{
|
|
193
|
-
"connectorId":
|
|
252
|
+
"connectorId": cfg.connector_id,
|
|
194
253
|
"idTag": cfg.rfid,
|
|
195
254
|
"meterStart": meter_start,
|
|
196
255
|
"vin": cfg.vin,
|
|
@@ -224,11 +283,13 @@ class ChargePointSimulator:
|
|
|
224
283
|
"meter",
|
|
225
284
|
"MeterValues",
|
|
226
285
|
{
|
|
227
|
-
"connectorId":
|
|
286
|
+
"connectorId": cfg.connector_id,
|
|
228
287
|
"transactionId": tx_id,
|
|
229
288
|
"meterValue": [
|
|
230
289
|
{
|
|
231
|
-
"timestamp": time.strftime(
|
|
290
|
+
"timestamp": time.strftime(
|
|
291
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
292
|
+
),
|
|
232
293
|
"sampledValue": [
|
|
233
294
|
{
|
|
234
295
|
"value": f"{meter_kw:.3f}",
|
|
@@ -243,6 +304,7 @@ class ChargePointSimulator:
|
|
|
243
304
|
)
|
|
244
305
|
)
|
|
245
306
|
await recv()
|
|
307
|
+
await self._maybe_send_door_event(send, recv)
|
|
246
308
|
await asyncio.sleep(cfg.interval)
|
|
247
309
|
|
|
248
310
|
await send(
|
|
@@ -260,6 +322,7 @@ class ChargePointSimulator:
|
|
|
260
322
|
)
|
|
261
323
|
)
|
|
262
324
|
await recv()
|
|
325
|
+
await self._maybe_send_door_event(send, recv)
|
|
263
326
|
except asyncio.TimeoutError:
|
|
264
327
|
if not self._connected.is_set():
|
|
265
328
|
self._connect_error = "Timeout waiting for response"
|
|
@@ -329,6 +392,7 @@ class ChargePointSimulator:
|
|
|
329
392
|
self.status = "starting"
|
|
330
393
|
self._connected.clear()
|
|
331
394
|
self._connect_error = ""
|
|
395
|
+
self._door_open_event.clear()
|
|
332
396
|
|
|
333
397
|
def _runner() -> None:
|
|
334
398
|
asyncio.run(self._run())
|
ocpp/store.py
CHANGED
|
@@ -6,21 +6,232 @@ from pathlib import Path
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
import json
|
|
8
8
|
import re
|
|
9
|
+
import asyncio
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
from core.log_paths import select_log_dir
|
|
12
|
+
|
|
13
|
+
IDENTITY_SEPARATOR = "#"
|
|
14
|
+
AGGREGATE_SLUG = "all"
|
|
15
|
+
PENDING_SLUG = "pending"
|
|
16
|
+
|
|
17
|
+
MAX_CONNECTIONS_PER_IP = 2
|
|
18
|
+
|
|
19
|
+
connections: dict[str, object] = {}
|
|
20
|
+
transactions: dict[str, object] = {}
|
|
12
21
|
logs: dict[str, dict[str, list[str]]] = {"charger": {}, "simulator": {}}
|
|
13
22
|
# store per charger session logs before they are flushed to disk
|
|
14
23
|
history: dict[str, dict[str, object]] = {}
|
|
15
24
|
simulators = {}
|
|
25
|
+
ip_connections: dict[str, set[object]] = {}
|
|
16
26
|
|
|
17
27
|
# mapping of charger id / cp_path to friendly names used for log files
|
|
18
28
|
log_names: dict[str, dict[str, str]] = {"charger": {}, "simulator": {}}
|
|
19
29
|
|
|
20
|
-
|
|
21
|
-
LOG_DIR
|
|
30
|
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
31
|
+
LOG_DIR = select_log_dir(BASE_DIR)
|
|
22
32
|
SESSION_DIR = LOG_DIR / "sessions"
|
|
23
33
|
SESSION_DIR.mkdir(exist_ok=True)
|
|
34
|
+
LOCK_DIR = BASE_DIR / "locks"
|
|
35
|
+
LOCK_DIR.mkdir(exist_ok=True)
|
|
36
|
+
SESSION_LOCK = LOCK_DIR / "charging.lck"
|
|
37
|
+
_lock_task: asyncio.Task | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def connector_slug(value: int | str | None) -> str:
|
|
41
|
+
"""Return the canonical slug for a connector value."""
|
|
42
|
+
|
|
43
|
+
if value in (None, "", AGGREGATE_SLUG):
|
|
44
|
+
return AGGREGATE_SLUG
|
|
45
|
+
try:
|
|
46
|
+
return str(int(value))
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
return str(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def identity_key(serial: str, connector: int | str | None) -> str:
|
|
52
|
+
"""Return the identity key used for in-memory store lookups."""
|
|
53
|
+
|
|
54
|
+
return f"{serial}{IDENTITY_SEPARATOR}{connector_slug(connector)}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def register_ip_connection(ip: str | None, consumer: object) -> bool:
|
|
58
|
+
"""Track a websocket connection for the provided client IP."""
|
|
59
|
+
|
|
60
|
+
if not ip:
|
|
61
|
+
return True
|
|
62
|
+
conns = ip_connections.setdefault(ip, set())
|
|
63
|
+
if consumer in conns:
|
|
64
|
+
return True
|
|
65
|
+
if len(conns) >= MAX_CONNECTIONS_PER_IP:
|
|
66
|
+
return False
|
|
67
|
+
conns.add(consumer)
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def release_ip_connection(ip: str | None, consumer: object) -> None:
|
|
72
|
+
"""Remove a websocket connection from the active client registry."""
|
|
73
|
+
|
|
74
|
+
if not ip:
|
|
75
|
+
return
|
|
76
|
+
conns = ip_connections.get(ip)
|
|
77
|
+
if not conns:
|
|
78
|
+
return
|
|
79
|
+
conns.discard(consumer)
|
|
80
|
+
if not conns:
|
|
81
|
+
ip_connections.pop(ip, None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def pending_key(serial: str) -> str:
|
|
85
|
+
"""Return the key used before a connector id has been negotiated."""
|
|
86
|
+
|
|
87
|
+
return f"{serial}{IDENTITY_SEPARATOR}{PENDING_SLUG}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _candidate_keys(serial: str, connector: int | str | None) -> list[str]:
|
|
91
|
+
"""Return possible keys for lookups with fallbacks."""
|
|
92
|
+
|
|
93
|
+
keys: list[str] = []
|
|
94
|
+
if connector not in (None, "", AGGREGATE_SLUG):
|
|
95
|
+
keys.append(identity_key(serial, connector))
|
|
96
|
+
else:
|
|
97
|
+
keys.append(identity_key(serial, None))
|
|
98
|
+
prefix = f"{serial}{IDENTITY_SEPARATOR}"
|
|
99
|
+
for key in connections.keys():
|
|
100
|
+
if key.startswith(prefix) and key not in keys:
|
|
101
|
+
keys.append(key)
|
|
102
|
+
keys.append(pending_key(serial))
|
|
103
|
+
keys.append(serial)
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
result: list[str] = []
|
|
106
|
+
for key in keys:
|
|
107
|
+
if key and key not in seen:
|
|
108
|
+
seen.add(key)
|
|
109
|
+
result.append(key)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def iter_identity_keys(serial: str) -> list[str]:
|
|
114
|
+
"""Return all known keys for the provided serial."""
|
|
115
|
+
|
|
116
|
+
prefix = f"{serial}{IDENTITY_SEPARATOR}"
|
|
117
|
+
keys = [key for key in connections.keys() if key.startswith(prefix)]
|
|
118
|
+
if serial in connections:
|
|
119
|
+
keys.append(serial)
|
|
120
|
+
return keys
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def is_connected(serial: str, connector: int | str | None = None) -> bool:
|
|
124
|
+
"""Return whether a connection exists for the provided charger identity."""
|
|
125
|
+
|
|
126
|
+
if connector in (None, "", AGGREGATE_SLUG):
|
|
127
|
+
prefix = f"{serial}{IDENTITY_SEPARATOR}"
|
|
128
|
+
return (
|
|
129
|
+
any(key.startswith(prefix) for key in connections) or serial in connections
|
|
130
|
+
)
|
|
131
|
+
return any(key in connections for key in _candidate_keys(serial, connector))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_connection(serial: str, connector: int | str | None = None):
|
|
135
|
+
"""Return the websocket consumer for the requested identity, if any."""
|
|
136
|
+
|
|
137
|
+
for key in _candidate_keys(serial, connector):
|
|
138
|
+
conn = connections.get(key)
|
|
139
|
+
if conn is not None:
|
|
140
|
+
return conn
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def set_connection(serial: str, connector: int | str | None, consumer) -> str:
|
|
145
|
+
"""Store a websocket consumer under the negotiated identity."""
|
|
146
|
+
|
|
147
|
+
key = identity_key(serial, connector)
|
|
148
|
+
connections[key] = consumer
|
|
149
|
+
return key
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def pop_connection(serial: str, connector: int | str | None = None):
|
|
153
|
+
"""Remove a stored connection for the given identity."""
|
|
154
|
+
|
|
155
|
+
for key in _candidate_keys(serial, connector):
|
|
156
|
+
conn = connections.pop(key, None)
|
|
157
|
+
if conn is not None:
|
|
158
|
+
return conn
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def get_transaction(serial: str, connector: int | str | None = None):
|
|
163
|
+
"""Return the active transaction for the provided identity."""
|
|
164
|
+
|
|
165
|
+
for key in _candidate_keys(serial, connector):
|
|
166
|
+
tx = transactions.get(key)
|
|
167
|
+
if tx is not None:
|
|
168
|
+
return tx
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def set_transaction(serial: str, connector: int | str | None, tx) -> str:
|
|
173
|
+
"""Store an active transaction under the provided identity."""
|
|
174
|
+
|
|
175
|
+
key = identity_key(serial, connector)
|
|
176
|
+
transactions[key] = tx
|
|
177
|
+
return key
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def pop_transaction(serial: str, connector: int | str | None = None):
|
|
181
|
+
"""Remove and return an active transaction for the identity."""
|
|
182
|
+
|
|
183
|
+
for key in _candidate_keys(serial, connector):
|
|
184
|
+
tx = transactions.pop(key, None)
|
|
185
|
+
if tx is not None:
|
|
186
|
+
return tx
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def reassign_identity(old_key: str, new_key: str) -> str:
|
|
191
|
+
"""Move any stored data from ``old_key`` to ``new_key``."""
|
|
192
|
+
|
|
193
|
+
if old_key == new_key:
|
|
194
|
+
return new_key
|
|
195
|
+
if not old_key:
|
|
196
|
+
return new_key
|
|
197
|
+
for mapping in (connections, transactions, history):
|
|
198
|
+
if old_key in mapping:
|
|
199
|
+
mapping[new_key] = mapping.pop(old_key)
|
|
200
|
+
for log_type in logs:
|
|
201
|
+
store = logs[log_type]
|
|
202
|
+
if old_key in store:
|
|
203
|
+
store[new_key] = store.pop(old_key)
|
|
204
|
+
for log_type in log_names:
|
|
205
|
+
names = log_names[log_type]
|
|
206
|
+
if old_key in names:
|
|
207
|
+
names[new_key] = names.pop(old_key)
|
|
208
|
+
return new_key
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _touch_lock() -> None:
|
|
212
|
+
try:
|
|
213
|
+
while True:
|
|
214
|
+
SESSION_LOCK.touch()
|
|
215
|
+
await asyncio.sleep(60)
|
|
216
|
+
except asyncio.CancelledError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def start_session_lock() -> None:
|
|
221
|
+
global _lock_task
|
|
222
|
+
SESSION_LOCK.touch()
|
|
223
|
+
loop = asyncio.get_event_loop()
|
|
224
|
+
if _lock_task is None or _lock_task.done():
|
|
225
|
+
_lock_task = loop.create_task(_touch_lock())
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def stop_session_lock() -> None:
|
|
229
|
+
global _lock_task
|
|
230
|
+
if _lock_task:
|
|
231
|
+
_lock_task.cancel()
|
|
232
|
+
_lock_task = None
|
|
233
|
+
if SESSION_LOCK.exists():
|
|
234
|
+
SESSION_LOCK.unlink()
|
|
24
235
|
|
|
25
236
|
|
|
26
237
|
def register_log_name(cid: str, name: str, log_type: str = "charger") -> None:
|
|
@@ -86,10 +297,12 @@ def add_session_message(cid: str, message: str) -> None:
|
|
|
86
297
|
sess = history.get(cid)
|
|
87
298
|
if not sess:
|
|
88
299
|
return
|
|
89
|
-
sess["messages"].append(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
300
|
+
sess["messages"].append(
|
|
301
|
+
{
|
|
302
|
+
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
303
|
+
"message": message,
|
|
304
|
+
}
|
|
305
|
+
)
|
|
93
306
|
|
|
94
307
|
|
|
95
308
|
def end_session_log(cid: str) -> None:
|
|
@@ -107,15 +320,33 @@ def end_session_log(cid: str) -> None:
|
|
|
107
320
|
json.dump(sess["messages"], handle, ensure_ascii=False, indent=2)
|
|
108
321
|
|
|
109
322
|
|
|
110
|
-
def
|
|
111
|
-
"""Return
|
|
323
|
+
def _log_key_candidates(cid: str, log_type: str) -> list[str]:
|
|
324
|
+
"""Return log identifiers to inspect for the requested cid."""
|
|
325
|
+
|
|
326
|
+
if IDENTITY_SEPARATOR not in cid:
|
|
327
|
+
return [cid]
|
|
328
|
+
serial, slug = cid.split(IDENTITY_SEPARATOR, 1)
|
|
329
|
+
slug = slug or AGGREGATE_SLUG
|
|
330
|
+
if slug != AGGREGATE_SLUG:
|
|
331
|
+
return [cid]
|
|
332
|
+
keys: list[str] = [identity_key(serial, None)]
|
|
333
|
+
prefix = f"{serial}{IDENTITY_SEPARATOR}"
|
|
334
|
+
for source in (log_names[log_type], logs[log_type]):
|
|
335
|
+
for key in source.keys():
|
|
336
|
+
if key.startswith(prefix) and key not in keys:
|
|
337
|
+
keys.append(key)
|
|
338
|
+
return keys
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _resolve_log_identifier(cid: str, log_type: str) -> tuple[str, str | None]:
|
|
342
|
+
"""Return the canonical key and friendly name for ``cid``."""
|
|
112
343
|
|
|
113
344
|
names = log_names[log_type]
|
|
114
|
-
# Try to find a matching log name case-insensitively
|
|
115
345
|
name = names.get(cid)
|
|
116
346
|
if name is None:
|
|
347
|
+
lower = cid.lower()
|
|
117
348
|
for key, value in names.items():
|
|
118
|
-
if key.lower() ==
|
|
349
|
+
if key.lower() == lower:
|
|
119
350
|
cid = key
|
|
120
351
|
name = value
|
|
121
352
|
break
|
|
@@ -132,14 +363,17 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
|
|
|
132
363
|
else:
|
|
133
364
|
from .models import Charger
|
|
134
365
|
|
|
135
|
-
|
|
366
|
+
serial = cid.split(IDENTITY_SEPARATOR, 1)[0]
|
|
367
|
+
ch = Charger.objects.filter(charger_id__iexact=serial).first()
|
|
136
368
|
if ch and ch.name:
|
|
137
|
-
cid = ch.charger_id
|
|
138
369
|
name = ch.name
|
|
139
370
|
names[cid] = name
|
|
140
371
|
except Exception: # pragma: no cover - best effort lookup
|
|
141
372
|
pass
|
|
373
|
+
return cid, name
|
|
374
|
+
|
|
142
375
|
|
|
376
|
+
def _log_file_for_identifier(cid: str, name: str | None, log_type: str) -> Path:
|
|
143
377
|
path = _file_path(cid, log_type)
|
|
144
378
|
if not path.exists():
|
|
145
379
|
target = f"{log_type}.{_safe_name(name or cid).lower()}"
|
|
@@ -147,29 +381,53 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
|
|
|
147
381
|
if file.stem.lower() == target:
|
|
148
382
|
path = file
|
|
149
383
|
break
|
|
384
|
+
return path
|
|
150
385
|
|
|
151
|
-
if path.exists():
|
|
152
|
-
return path.read_text(encoding="utf-8").splitlines()
|
|
153
386
|
|
|
387
|
+
def _memory_logs_for_identifier(cid: str, log_type: str) -> list[str]:
|
|
154
388
|
store = logs[log_type]
|
|
389
|
+
lower = cid.lower()
|
|
155
390
|
for key, entries in store.items():
|
|
156
|
-
if key.lower() ==
|
|
391
|
+
if key.lower() == lower:
|
|
157
392
|
return entries
|
|
158
393
|
return []
|
|
159
394
|
|
|
160
395
|
|
|
396
|
+
def get_logs(cid: str, log_type: str = "charger") -> list[str]:
|
|
397
|
+
"""Return all log entries for the given id and type."""
|
|
398
|
+
|
|
399
|
+
entries: list[str] = []
|
|
400
|
+
seen_paths: set[Path] = set()
|
|
401
|
+
seen_keys: set[str] = set()
|
|
402
|
+
for key in _log_key_candidates(cid, log_type):
|
|
403
|
+
resolved, name = _resolve_log_identifier(key, log_type)
|
|
404
|
+
path = _log_file_for_identifier(resolved, name, log_type)
|
|
405
|
+
if path.exists() and path not in seen_paths:
|
|
406
|
+
entries.extend(path.read_text(encoding="utf-8").splitlines())
|
|
407
|
+
seen_paths.add(path)
|
|
408
|
+
memory_entries = _memory_logs_for_identifier(resolved, log_type)
|
|
409
|
+
lower_key = resolved.lower()
|
|
410
|
+
if memory_entries and lower_key not in seen_keys:
|
|
411
|
+
entries.extend(memory_entries)
|
|
412
|
+
seen_keys.add(lower_key)
|
|
413
|
+
return entries
|
|
414
|
+
|
|
415
|
+
|
|
161
416
|
def clear_log(cid: str, log_type: str = "charger") -> None:
|
|
162
417
|
"""Remove any stored logs for the given id and type."""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
418
|
+
for key in _log_key_candidates(cid, log_type):
|
|
419
|
+
store_map = logs[log_type]
|
|
420
|
+
resolved = next(
|
|
421
|
+
(k for k in list(store_map.keys()) if k.lower() == key.lower()),
|
|
422
|
+
key,
|
|
423
|
+
)
|
|
424
|
+
store_map.pop(resolved, None)
|
|
425
|
+
path = _file_path(resolved, log_type)
|
|
426
|
+
if not path.exists():
|
|
427
|
+
target = f"{log_type}.{_safe_name(log_names[log_type].get(resolved, resolved)).lower()}"
|
|
428
|
+
for file in LOG_DIR.glob(f"{log_type}.*.log"):
|
|
429
|
+
if file.stem.lower() == target:
|
|
430
|
+
path = file
|
|
431
|
+
break
|
|
432
|
+
if path.exists():
|
|
433
|
+
path.unlink()
|
ocpp/tasks.py
CHANGED
|
@@ -5,23 +5,27 @@ from celery import shared_task
|
|
|
5
5
|
from django.utils import timezone
|
|
6
6
|
from django.db.models import Q
|
|
7
7
|
|
|
8
|
-
from .models import
|
|
8
|
+
from .models import MeterValue
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
@shared_task
|
|
14
|
-
def
|
|
15
|
-
"""Delete meter
|
|
14
|
+
def purge_meter_values() -> int:
|
|
15
|
+
"""Delete meter values older than 7 days.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Values tied to transactions without a recorded meter_stop are preserved so
|
|
18
18
|
that ongoing or incomplete sessions retain their energy data.
|
|
19
|
-
Returns the number of deleted
|
|
19
|
+
Returns the number of deleted rows.
|
|
20
20
|
"""
|
|
21
21
|
cutoff = timezone.now() - timedelta(days=7)
|
|
22
|
-
qs =
|
|
22
|
+
qs = MeterValue.objects.filter(timestamp__lt=cutoff).filter(
|
|
23
23
|
Q(transaction__isnull=True) | Q(transaction__meter_stop__isnull=False)
|
|
24
24
|
)
|
|
25
25
|
deleted, _ = qs.delete()
|
|
26
|
-
logger.info("Purged %s meter
|
|
26
|
+
logger.info("Purged %s meter values", deleted)
|
|
27
27
|
return deleted
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Backwards compatibility alias
|
|
31
|
+
purge_meter_readings = purge_meter_values
|
ocpp/test_export_import.py
CHANGED
|
@@ -10,7 +10,7 @@ from django.utils import timezone
|
|
|
10
10
|
from django.urls import reverse
|
|
11
11
|
from django.contrib.auth import get_user_model
|
|
12
12
|
|
|
13
|
-
from ocpp.models import Charger, Transaction,
|
|
13
|
+
from ocpp.models import Charger, Transaction, MeterValue
|
|
14
14
|
from core.models import EnergyAccount
|
|
15
15
|
|
|
16
16
|
|
|
@@ -29,12 +29,11 @@ class TransactionExportImportTests(TestCase):
|
|
|
29
29
|
charger=self.ch2,
|
|
30
30
|
start_time=now,
|
|
31
31
|
)
|
|
32
|
-
|
|
32
|
+
MeterValue.objects.create(
|
|
33
33
|
charger=self.ch1,
|
|
34
34
|
transaction=self.tx_old,
|
|
35
35
|
timestamp=now - timedelta(days=5),
|
|
36
|
-
|
|
37
|
-
unit="kW",
|
|
36
|
+
energy=1,
|
|
38
37
|
)
|
|
39
38
|
|
|
40
39
|
def test_export_filters_and_import_creates_chargers(self):
|
|
@@ -55,7 +54,7 @@ class TransactionExportImportTests(TestCase):
|
|
|
55
54
|
self.assertEqual(len(data["transactions"]), 1)
|
|
56
55
|
self.assertEqual(data["transactions"][0]["charger"], "C2")
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
MeterValue.objects.all().delete()
|
|
59
58
|
Transaction.objects.all().delete()
|
|
60
59
|
Charger.objects.all().delete()
|
|
61
60
|
|
|
@@ -117,7 +116,7 @@ class TransactionAdminExportImportTests(TestCase):
|
|
|
117
116
|
"meter_stop": 0,
|
|
118
117
|
"start_time": timezone.now().isoformat(),
|
|
119
118
|
"stop_time": None,
|
|
120
|
-
"
|
|
119
|
+
"meter_values": [],
|
|
121
120
|
}
|
|
122
121
|
],
|
|
123
122
|
}
|
|
@@ -126,4 +125,6 @@ class TransactionAdminExportImportTests(TestCase):
|
|
|
126
125
|
response = self.client.post(url, {"file": json_file})
|
|
127
126
|
self.assertEqual(response.status_code, 302)
|
|
128
127
|
self.assertTrue(Charger.objects.filter(charger_id="C9").exists())
|
|
129
|
-
self.assertEqual(
|
|
128
|
+
self.assertEqual(
|
|
129
|
+
Transaction.objects.filter(charger__charger_id="C9").count(), 1
|
|
130
|
+
)
|