arthexis 0.1.16__py3-none-any.whl → 0.1.28__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.16.dist-info → arthexis-0.1.28.dist-info}/METADATA +95 -41
- arthexis-0.1.28.dist-info/RECORD +112 -0
- config/asgi.py +1 -15
- config/middleware.py +47 -1
- config/settings.py +21 -30
- config/settings_helpers.py +176 -1
- config/urls.py +69 -1
- core/admin.py +805 -473
- core/apps.py +6 -8
- core/auto_upgrade.py +19 -4
- core/backends.py +13 -3
- core/celery_utils.py +73 -0
- core/changelog.py +66 -5
- core/environment.py +4 -5
- core/models.py +1825 -218
- core/notifications.py +1 -1
- core/reference_utils.py +10 -11
- core/release.py +55 -7
- core/sigil_builder.py +2 -2
- core/sigil_resolver.py +1 -66
- core/system.py +285 -4
- core/tasks.py +439 -138
- core/test_system_info.py +43 -5
- core/tests.py +516 -18
- core/user_data.py +94 -21
- core/views.py +348 -186
- nodes/admin.py +904 -67
- nodes/apps.py +12 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +800 -127
- nodes/rfid_sync.py +1 -1
- nodes/tasks.py +98 -3
- nodes/tests.py +1381 -152
- nodes/urls.py +15 -1
- nodes/utils.py +51 -3
- nodes/views.py +1382 -152
- ocpp/admin.py +1970 -152
- ocpp/consumers.py +839 -34
- ocpp/models.py +968 -17
- ocpp/network.py +398 -0
- ocpp/store.py +411 -43
- ocpp/tasks.py +261 -3
- ocpp/test_export_import.py +1 -0
- ocpp/test_rfid.py +194 -6
- ocpp/tests.py +1918 -87
- ocpp/transactions_io.py +9 -1
- ocpp/urls.py +8 -3
- ocpp/views.py +700 -53
- pages/admin.py +262 -30
- pages/apps.py +35 -0
- pages/context_processors.py +28 -21
- pages/defaults.py +1 -1
- pages/forms.py +31 -8
- pages/middleware.py +6 -2
- pages/models.py +86 -2
- pages/module_defaults.py +5 -5
- pages/site_config.py +137 -0
- pages/tests.py +1050 -126
- pages/urls.py +14 -2
- pages/utils.py +70 -0
- pages/views.py +622 -56
- arthexis-0.1.16.dist-info/RECORD +0 -111
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.16.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/tasks.py
CHANGED
|
@@ -1,21 +1,174 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
from dataclasses import dataclass
|
|
2
5
|
from datetime import date, datetime, time, timedelta
|
|
3
6
|
from pathlib import Path
|
|
7
|
+
from typing import Iterable
|
|
4
8
|
|
|
9
|
+
from asgiref.sync import async_to_sync
|
|
5
10
|
from celery import shared_task
|
|
6
11
|
from django.conf import settings
|
|
7
12
|
from django.contrib.auth import get_user_model
|
|
13
|
+
from django.db.models import Q, Prefetch
|
|
8
14
|
from django.utils import timezone
|
|
9
|
-
from
|
|
15
|
+
from urllib.parse import quote, urlsplit, urlunsplit
|
|
16
|
+
from websocket import WebSocketException, create_connection
|
|
10
17
|
|
|
11
18
|
from core import mailer
|
|
12
19
|
from nodes.models import Node
|
|
13
20
|
|
|
14
|
-
from .
|
|
15
|
-
|
|
21
|
+
from . import store
|
|
22
|
+
from .models import Charger, MeterValue, Transaction
|
|
16
23
|
logger = logging.getLogger(__name__)
|
|
17
24
|
|
|
18
25
|
|
|
26
|
+
@dataclass
|
|
27
|
+
class ForwardingSession:
|
|
28
|
+
"""Active websocket forwarding session for a charge point."""
|
|
29
|
+
|
|
30
|
+
charger_pk: int
|
|
31
|
+
node_id: int | None
|
|
32
|
+
url: str
|
|
33
|
+
connection: object
|
|
34
|
+
connected_at: datetime
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_connected(self) -> bool:
|
|
38
|
+
return bool(getattr(self.connection, "connected", False))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_FORWARDING_SESSIONS: dict[int, ForwardingSession] = {}
|
|
42
|
+
|
|
43
|
+
def _candidate_forwarding_urls(node: Node, charger: Charger) -> Iterable[str]:
|
|
44
|
+
"""Yield websocket URLs suitable for forwarding ``charger`` via ``node``."""
|
|
45
|
+
|
|
46
|
+
charger_id = (charger.charger_id or "").strip()
|
|
47
|
+
if not charger_id:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
encoded_id = quote(charger_id, safe="")
|
|
51
|
+
for base in node.iter_remote_urls("/"):
|
|
52
|
+
if not base:
|
|
53
|
+
continue
|
|
54
|
+
parsed = urlsplit(base)
|
|
55
|
+
if parsed.scheme not in {"http", "https"}:
|
|
56
|
+
continue
|
|
57
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
58
|
+
base_path = parsed.path.rstrip("/")
|
|
59
|
+
for prefix in ("", "/ws"):
|
|
60
|
+
path = f"{base_path}{prefix}/{encoded_id}".replace("//", "/")
|
|
61
|
+
if not path.startswith("/"):
|
|
62
|
+
path = f"/{path}"
|
|
63
|
+
yield urlunsplit((scheme, parsed.netloc, path, "", ""))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _close_forwarding_session(session: ForwardingSession) -> None:
|
|
67
|
+
"""Close the websocket connection associated with ``session`` if open."""
|
|
68
|
+
|
|
69
|
+
connection = session.connection
|
|
70
|
+
if connection is None:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
connection.close()
|
|
74
|
+
except Exception: # pragma: no cover - best effort close
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@shared_task
|
|
79
|
+
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
80
|
+
"""Request the latest configuration from a connected charge point."""
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
charger = Charger.objects.get(pk=charger_pk)
|
|
84
|
+
except Charger.DoesNotExist:
|
|
85
|
+
logger.warning(
|
|
86
|
+
"Unable to request configuration for missing charger %s",
|
|
87
|
+
charger_pk,
|
|
88
|
+
)
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
connector_value = charger.connector_id
|
|
92
|
+
if connector_value is not None:
|
|
93
|
+
logger.debug(
|
|
94
|
+
"Skipping charger %s: connector %s is not eligible for automatic configuration checks",
|
|
95
|
+
charger.charger_id,
|
|
96
|
+
connector_value,
|
|
97
|
+
)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
101
|
+
if ws is None:
|
|
102
|
+
logger.info(
|
|
103
|
+
"Charge point %s is not connected; configuration request skipped",
|
|
104
|
+
charger.charger_id,
|
|
105
|
+
)
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
message_id = uuid.uuid4().hex
|
|
109
|
+
payload: dict[str, object] = {}
|
|
110
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
async_to_sync(ws.send)(msg)
|
|
114
|
+
except Exception as exc: # pragma: no cover - network error
|
|
115
|
+
logger.warning(
|
|
116
|
+
"Failed to send GetConfiguration to %s (%s)",
|
|
117
|
+
charger.charger_id,
|
|
118
|
+
exc,
|
|
119
|
+
)
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
123
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
124
|
+
store.register_pending_call(
|
|
125
|
+
message_id,
|
|
126
|
+
{
|
|
127
|
+
"action": "GetConfiguration",
|
|
128
|
+
"charger_id": charger.charger_id,
|
|
129
|
+
"connector_id": connector_value,
|
|
130
|
+
"log_key": log_key,
|
|
131
|
+
"requested_at": timezone.now(),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
store.schedule_call_timeout(
|
|
135
|
+
message_id,
|
|
136
|
+
timeout=5.0,
|
|
137
|
+
action="GetConfiguration",
|
|
138
|
+
log_key=log_key,
|
|
139
|
+
message=(
|
|
140
|
+
"GetConfiguration timed out: charger did not respond"
|
|
141
|
+
" (operation may not be supported)"
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
logger.info(
|
|
145
|
+
"Requested configuration from charge point %s",
|
|
146
|
+
charger.charger_id,
|
|
147
|
+
)
|
|
148
|
+
return True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@shared_task
|
|
152
|
+
def schedule_daily_charge_point_configuration_checks() -> int:
|
|
153
|
+
"""Dispatch configuration requests for eligible charge points."""
|
|
154
|
+
|
|
155
|
+
charger_ids = list(
|
|
156
|
+
Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
|
|
157
|
+
)
|
|
158
|
+
if not charger_ids:
|
|
159
|
+
logger.debug("No eligible charge points available for configuration check")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
scheduled = 0
|
|
163
|
+
for charger_pk in charger_ids:
|
|
164
|
+
check_charge_point_configuration.delay(charger_pk)
|
|
165
|
+
scheduled += 1
|
|
166
|
+
logger.info(
|
|
167
|
+
"Scheduled configuration checks for %s charge point(s)", scheduled
|
|
168
|
+
)
|
|
169
|
+
return scheduled
|
|
170
|
+
|
|
171
|
+
|
|
19
172
|
@shared_task
|
|
20
173
|
def purge_meter_values() -> int:
|
|
21
174
|
"""Delete meter values older than 7 days.
|
|
@@ -37,6 +190,101 @@ def purge_meter_values() -> int:
|
|
|
37
190
|
purge_meter_readings = purge_meter_values
|
|
38
191
|
|
|
39
192
|
|
|
193
|
+
@shared_task(rate_limit="1/10m")
|
|
194
|
+
def push_forwarded_charge_points() -> int:
|
|
195
|
+
"""Ensure websocket connections exist for forwarded charge points."""
|
|
196
|
+
|
|
197
|
+
local = Node.get_local()
|
|
198
|
+
if not local:
|
|
199
|
+
logger.debug("Forwarding skipped: local node not registered")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
chargers_qs = (
|
|
203
|
+
Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
|
|
204
|
+
.select_related("forwarded_to", "node_origin")
|
|
205
|
+
.order_by("pk")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
node_filter = Q(node_origin__isnull=True)
|
|
209
|
+
if local.pk:
|
|
210
|
+
node_filter |= Q(node_origin=local)
|
|
211
|
+
|
|
212
|
+
chargers = list(chargers_qs.filter(node_filter))
|
|
213
|
+
active_ids = {charger.pk for charger in chargers}
|
|
214
|
+
|
|
215
|
+
# Close sessions that no longer map to active forwarded chargers.
|
|
216
|
+
for pk in list(_FORWARDING_SESSIONS.keys()):
|
|
217
|
+
if pk not in active_ids:
|
|
218
|
+
session = _FORWARDING_SESSIONS.pop(pk)
|
|
219
|
+
_close_forwarding_session(session)
|
|
220
|
+
|
|
221
|
+
if not chargers:
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
connected = 0
|
|
225
|
+
|
|
226
|
+
for charger in chargers:
|
|
227
|
+
target = charger.forwarded_to
|
|
228
|
+
if not target:
|
|
229
|
+
continue
|
|
230
|
+
if local.pk and target.pk == local.pk:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
existing = _FORWARDING_SESSIONS.get(charger.pk)
|
|
234
|
+
if existing and existing.node_id == getattr(target, "pk", None):
|
|
235
|
+
if existing.is_connected:
|
|
236
|
+
continue
|
|
237
|
+
_close_forwarding_session(existing)
|
|
238
|
+
_FORWARDING_SESSIONS.pop(charger.pk, None)
|
|
239
|
+
|
|
240
|
+
for url in _candidate_forwarding_urls(target, charger):
|
|
241
|
+
try:
|
|
242
|
+
connection = create_connection(
|
|
243
|
+
url,
|
|
244
|
+
timeout=5,
|
|
245
|
+
subprotocols=["ocpp1.6"],
|
|
246
|
+
)
|
|
247
|
+
except (WebSocketException, OSError) as exc:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Websocket forwarding connection to %s via %s failed: %s",
|
|
250
|
+
target,
|
|
251
|
+
url,
|
|
252
|
+
exc,
|
|
253
|
+
)
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
session = ForwardingSession(
|
|
257
|
+
charger_pk=charger.pk,
|
|
258
|
+
node_id=getattr(target, "pk", None),
|
|
259
|
+
url=url,
|
|
260
|
+
connection=connection,
|
|
261
|
+
connected_at=timezone.now(),
|
|
262
|
+
)
|
|
263
|
+
_FORWARDING_SESSIONS[charger.pk] = session
|
|
264
|
+
Charger.objects.filter(pk=charger.pk).update(
|
|
265
|
+
forwarding_watermark=session.connected_at
|
|
266
|
+
)
|
|
267
|
+
connected += 1
|
|
268
|
+
logger.info(
|
|
269
|
+
"Established forwarding websocket for charger %s to %s via %s",
|
|
270
|
+
charger.charger_id,
|
|
271
|
+
target,
|
|
272
|
+
url,
|
|
273
|
+
)
|
|
274
|
+
break
|
|
275
|
+
else:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Unable to establish forwarding websocket for charger %s",
|
|
278
|
+
charger.charger_id or charger.pk,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return connected
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# Backwards compatibility alias for legacy schedules
|
|
285
|
+
sync_remote_chargers = push_forwarded_charge_points
|
|
286
|
+
|
|
287
|
+
|
|
40
288
|
def _resolve_report_window() -> tuple[datetime, datetime, date]:
|
|
41
289
|
"""Return the start/end datetimes for today's reporting window."""
|
|
42
290
|
|
|
@@ -113,9 +361,15 @@ def send_daily_session_report() -> int:
|
|
|
113
361
|
return 0
|
|
114
362
|
|
|
115
363
|
start, end, today = _resolve_report_window()
|
|
364
|
+
meter_value_prefetch = Prefetch(
|
|
365
|
+
"meter_values",
|
|
366
|
+
queryset=MeterValue.objects.filter(energy__isnull=False).order_by("timestamp"),
|
|
367
|
+
to_attr="prefetched_meter_values",
|
|
368
|
+
)
|
|
116
369
|
transactions = list(
|
|
117
370
|
Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
|
|
118
371
|
.select_related("charger", "account")
|
|
372
|
+
.prefetch_related(meter_value_prefetch)
|
|
119
373
|
.order_by("start_time")
|
|
120
374
|
)
|
|
121
375
|
if not transactions:
|
|
@@ -149,6 +403,10 @@ def send_daily_session_report() -> int:
|
|
|
149
403
|
lines.append(f" Account: {account}")
|
|
150
404
|
if transaction.rfid:
|
|
151
405
|
lines.append(f" RFID: {transaction.rfid}")
|
|
406
|
+
identifier = transaction.vehicle_identifier
|
|
407
|
+
if identifier:
|
|
408
|
+
label = "VID" if transaction.vehicle_identifier_source == "vid" else "VIN"
|
|
409
|
+
lines.append(f" {label}: {identifier}")
|
|
152
410
|
if connector:
|
|
153
411
|
lines.append(f" {connector}")
|
|
154
412
|
lines.append(
|
ocpp/test_export_import.py
CHANGED
ocpp/test_rfid.py
CHANGED
|
@@ -131,7 +131,7 @@ class ScanNextViewTests(TestCase):
|
|
|
131
131
|
self.assertEqual(
|
|
132
132
|
resp.json(), {"rfid": "ABCD1234", "label_id": 1, "created": False}
|
|
133
133
|
)
|
|
134
|
-
mock_validate.assert_called_once_with("ABCD1234", kind=None)
|
|
134
|
+
mock_validate.assert_called_once_with("ABCD1234", kind=None, endianness=None)
|
|
135
135
|
|
|
136
136
|
@patch("config.middleware.Node.get_local", return_value=None)
|
|
137
137
|
@patch("config.middleware.get_site")
|
|
@@ -342,16 +342,20 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
342
342
|
tag.released = False
|
|
343
343
|
tag.reference = None
|
|
344
344
|
tag.kind = RFID.CLASSIC
|
|
345
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
345
346
|
mock_register.return_value = (tag, True)
|
|
346
347
|
|
|
347
348
|
result = validate_rfid_value("abcd1234")
|
|
348
349
|
|
|
349
|
-
mock_register.assert_called_once_with(
|
|
350
|
+
mock_register.assert_called_once_with(
|
|
351
|
+
"ABCD1234", kind=None, endianness=RFID.BIG_ENDIAN
|
|
352
|
+
)
|
|
350
353
|
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
351
354
|
self.assertIs(tag.last_seen_on, fake_now)
|
|
352
355
|
mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
|
|
353
356
|
self.assertTrue(result["created"])
|
|
354
357
|
self.assertEqual(result["rfid"], "ABCD1234")
|
|
358
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
355
359
|
|
|
356
360
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
357
361
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -367,11 +371,14 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
367
371
|
tag.released = True
|
|
368
372
|
tag.reference = None
|
|
369
373
|
tag.kind = RFID.CLASSIC
|
|
374
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
370
375
|
mock_register.return_value = (tag, False)
|
|
371
376
|
|
|
372
377
|
result = validate_rfid_value("abcd", kind=RFID.NTAG215)
|
|
373
378
|
|
|
374
|
-
mock_register.assert_called_once_with(
|
|
379
|
+
mock_register.assert_called_once_with(
|
|
380
|
+
"ABCD", kind=RFID.NTAG215, endianness=RFID.BIG_ENDIAN
|
|
381
|
+
)
|
|
375
382
|
tag.save.assert_called_once_with(update_fields=["kind", "last_seen_on"])
|
|
376
383
|
self.assertIs(tag.last_seen_on, fake_now)
|
|
377
384
|
self.assertEqual(tag.kind, RFID.NTAG215)
|
|
@@ -379,6 +386,36 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
379
386
|
self.assertFalse(result["allowed"])
|
|
380
387
|
self.assertFalse(result["created"])
|
|
381
388
|
self.assertEqual(result["kind"], RFID.NTAG215)
|
|
389
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
390
|
+
|
|
391
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
392
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
393
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
394
|
+
def test_registers_little_endian_value(
|
|
395
|
+
self, mock_register, mock_notify, mock_now
|
|
396
|
+
):
|
|
397
|
+
fake_now = object()
|
|
398
|
+
mock_now.return_value = fake_now
|
|
399
|
+
tag = MagicMock()
|
|
400
|
+
tag.pk = 7
|
|
401
|
+
tag.label_id = 7
|
|
402
|
+
tag.allowed = True
|
|
403
|
+
tag.color = "B"
|
|
404
|
+
tag.released = False
|
|
405
|
+
tag.reference = None
|
|
406
|
+
tag.kind = RFID.CLASSIC
|
|
407
|
+
tag.endianness = RFID.LITTLE_ENDIAN
|
|
408
|
+
mock_register.return_value = (tag, True)
|
|
409
|
+
|
|
410
|
+
result = validate_rfid_value("A1B2C3D4", endianness=RFID.LITTLE_ENDIAN)
|
|
411
|
+
|
|
412
|
+
mock_register.assert_called_once_with(
|
|
413
|
+
"D4C3B2A1", kind=None, endianness=RFID.LITTLE_ENDIAN
|
|
414
|
+
)
|
|
415
|
+
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
416
|
+
self.assertEqual(result["rfid"], "D4C3B2A1")
|
|
417
|
+
self.assertEqual(result["endianness"], RFID.LITTLE_ENDIAN)
|
|
418
|
+
mock_notify.assert_called_once()
|
|
382
419
|
|
|
383
420
|
def test_rejects_invalid_value(self):
|
|
384
421
|
result = validate_rfid_value("invalid!")
|
|
@@ -412,6 +449,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
412
449
|
tag.released = False
|
|
413
450
|
tag.reference = None
|
|
414
451
|
tag.kind = RFID.CLASSIC
|
|
452
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
415
453
|
mock_register.return_value = (tag, False)
|
|
416
454
|
mock_run.return_value = types.SimpleNamespace(
|
|
417
455
|
returncode=0, stdout="ok\n", stderr=""
|
|
@@ -427,6 +465,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
427
465
|
env = run_kwargs.get("env", {})
|
|
428
466
|
self.assertEqual(env.get("RFID_VALUE"), "ABCD1234")
|
|
429
467
|
self.assertEqual(env.get("RFID_LABEL_ID"), "1")
|
|
468
|
+
self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
|
|
430
469
|
mock_popen.assert_not_called()
|
|
431
470
|
mock_notify.assert_called_once_with("RFID 1 OK", "ABCD1234 B")
|
|
432
471
|
tag.save.assert_called_once_with(update_fields=["last_seen_on"])
|
|
@@ -437,6 +476,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
437
476
|
self.assertEqual(output.get("stderr"), "")
|
|
438
477
|
self.assertEqual(output.get("returncode"), 0)
|
|
439
478
|
self.assertEqual(output.get("error"), "")
|
|
479
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
440
480
|
|
|
441
481
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
442
482
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -457,6 +497,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
457
497
|
tag.released = False
|
|
458
498
|
tag.reference = None
|
|
459
499
|
tag.kind = RFID.CLASSIC
|
|
500
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
460
501
|
mock_register.return_value = (tag, False)
|
|
461
502
|
mock_run.return_value = types.SimpleNamespace(
|
|
462
503
|
returncode=1, stdout="", stderr="failure"
|
|
@@ -476,6 +517,77 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
476
517
|
self.assertEqual(output.get("stderr"), "failure")
|
|
477
518
|
self.assertEqual(output.get("error"), "")
|
|
478
519
|
mock_popen.assert_not_called()
|
|
520
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
521
|
+
|
|
522
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
523
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
524
|
+
@patch("ocpp.rfid.reader.subprocess.Popen")
|
|
525
|
+
@patch("ocpp.rfid.reader.subprocess.run")
|
|
526
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
527
|
+
def test_external_command_strips_trailing_percent_tokens(
|
|
528
|
+
self, mock_register, mock_run, mock_popen, mock_notify, mock_now
|
|
529
|
+
):
|
|
530
|
+
mock_now.return_value = timezone.now()
|
|
531
|
+
tag = MagicMock()
|
|
532
|
+
tag.pk = 3
|
|
533
|
+
tag.label_id = 3
|
|
534
|
+
tag.allowed = True
|
|
535
|
+
tag.external_command = "echo weird"
|
|
536
|
+
tag.color = "Y"
|
|
537
|
+
tag.released = False
|
|
538
|
+
tag.reference = None
|
|
539
|
+
tag.kind = RFID.CLASSIC
|
|
540
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
541
|
+
mock_register.return_value = (tag, False)
|
|
542
|
+
mock_run.return_value = types.SimpleNamespace(
|
|
543
|
+
returncode=0,
|
|
544
|
+
stdout="first %\nsecond 50%\r\nthird % %\n",
|
|
545
|
+
stderr="oops %\n",
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
result = validate_rfid_value("abc3")
|
|
549
|
+
|
|
550
|
+
output = result.get("command_output")
|
|
551
|
+
self.assertIsNotNone(output)
|
|
552
|
+
self.assertEqual(
|
|
553
|
+
output.get("stdout"), "first\nsecond 50%\r\nthird\n"
|
|
554
|
+
)
|
|
555
|
+
self.assertEqual(output.get("stderr"), "oops\n")
|
|
556
|
+
self.assertEqual(output.get("returncode"), 0)
|
|
557
|
+
self.assertEqual(output.get("error"), "")
|
|
558
|
+
mock_popen.assert_not_called()
|
|
559
|
+
|
|
560
|
+
@patch("ocpp.rfid.reader.timezone.now")
|
|
561
|
+
@patch("ocpp.rfid.reader.notify_async")
|
|
562
|
+
@patch("ocpp.rfid.reader.subprocess.Popen")
|
|
563
|
+
@patch("ocpp.rfid.reader.subprocess.run")
|
|
564
|
+
@patch("ocpp.rfid.reader.RFID.register_scan")
|
|
565
|
+
def test_external_command_error_strips_trailing_percent_tokens(
|
|
566
|
+
self, mock_register, mock_run, mock_popen, mock_notify, mock_now
|
|
567
|
+
):
|
|
568
|
+
mock_now.return_value = timezone.now()
|
|
569
|
+
tag = MagicMock()
|
|
570
|
+
tag.pk = 4
|
|
571
|
+
tag.label_id = 4
|
|
572
|
+
tag.allowed = True
|
|
573
|
+
tag.external_command = "echo boom"
|
|
574
|
+
tag.color = "R"
|
|
575
|
+
tag.released = False
|
|
576
|
+
tag.reference = None
|
|
577
|
+
tag.kind = RFID.CLASSIC
|
|
578
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
579
|
+
mock_register.return_value = (tag, False)
|
|
580
|
+
mock_run.side_effect = RuntimeError("bad % %")
|
|
581
|
+
|
|
582
|
+
result = validate_rfid_value("abcd")
|
|
583
|
+
|
|
584
|
+
output = result.get("command_output")
|
|
585
|
+
self.assertIsInstance(output, dict)
|
|
586
|
+
self.assertEqual(output.get("stdout"), "")
|
|
587
|
+
self.assertEqual(output.get("stderr"), "")
|
|
588
|
+
self.assertEqual(output.get("error"), "bad")
|
|
589
|
+
self.assertFalse(result["allowed"])
|
|
590
|
+
mock_popen.assert_not_called()
|
|
479
591
|
|
|
480
592
|
@patch("ocpp.rfid.reader.timezone.now")
|
|
481
593
|
@patch("ocpp.rfid.reader.notify_async")
|
|
@@ -497,6 +609,7 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
497
609
|
tag.released = False
|
|
498
610
|
tag.reference = None
|
|
499
611
|
tag.kind = RFID.CLASSIC
|
|
612
|
+
tag.endianness = RFID.BIG_ENDIAN
|
|
500
613
|
mock_register.return_value = (tag, False)
|
|
501
614
|
result = validate_rfid_value("abcdef")
|
|
502
615
|
|
|
@@ -507,10 +620,12 @@ class ValidateRfidValueTests(SimpleTestCase):
|
|
|
507
620
|
env = kwargs.get("env", {})
|
|
508
621
|
self.assertEqual(env.get("RFID_VALUE"), "ABCDEF")
|
|
509
622
|
self.assertEqual(env.get("RFID_LABEL_ID"), "3")
|
|
623
|
+
self.assertEqual(env.get("RFID_ENDIANNESS"), RFID.BIG_ENDIAN)
|
|
510
624
|
self.assertIs(kwargs.get("stdout"), subprocess.DEVNULL)
|
|
511
625
|
self.assertIs(kwargs.get("stderr"), subprocess.DEVNULL)
|
|
512
626
|
self.assertTrue(result["allowed"])
|
|
513
627
|
mock_notify.assert_called_once_with("RFID 3 OK", "ABCDEF B")
|
|
628
|
+
self.assertEqual(result["endianness"], RFID.BIG_ENDIAN)
|
|
514
629
|
|
|
515
630
|
|
|
516
631
|
class CardTypeDetectionTests(TestCase):
|
|
@@ -592,11 +707,12 @@ class RFIDLastSeenTests(TestCase):
|
|
|
592
707
|
|
|
593
708
|
class RFIDDetectionScriptTests(SimpleTestCase):
|
|
594
709
|
@patch("ocpp.rfid.detect._ensure_django")
|
|
710
|
+
@patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
|
|
595
711
|
@patch(
|
|
596
712
|
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
597
713
|
return_value={"irq_pin": DEFAULT_IRQ_PIN},
|
|
598
714
|
)
|
|
599
|
-
def test_detect_scanner_success(self, mock_check, _mock_setup):
|
|
715
|
+
def test_detect_scanner_success(self, mock_check, _mock_lock, _mock_setup):
|
|
600
716
|
result = detect_scanner()
|
|
601
717
|
self.assertEqual(
|
|
602
718
|
result,
|
|
@@ -608,16 +724,34 @@ class RFIDDetectionScriptTests(SimpleTestCase):
|
|
|
608
724
|
mock_check.assert_called_once()
|
|
609
725
|
|
|
610
726
|
@patch("ocpp.rfid.detect._ensure_django")
|
|
727
|
+
@patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
|
|
611
728
|
@patch(
|
|
612
729
|
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
613
730
|
return_value={"error": "no scanner detected"},
|
|
614
731
|
)
|
|
615
|
-
def test_detect_scanner_failure(self, mock_check, _mock_setup):
|
|
732
|
+
def test_detect_scanner_failure(self, mock_check, _mock_lock, _mock_setup):
|
|
616
733
|
result = detect_scanner()
|
|
617
734
|
self.assertFalse(result["detected"])
|
|
618
735
|
self.assertEqual(result["reason"], "no scanner detected")
|
|
619
736
|
mock_check.assert_called_once()
|
|
620
737
|
|
|
738
|
+
@patch("ocpp.rfid.detect._ensure_django")
|
|
739
|
+
@patch(
|
|
740
|
+
"ocpp.rfid.detect._lockfile_status",
|
|
741
|
+
return_value=(True, Path("/locks/rfid.lck")),
|
|
742
|
+
)
|
|
743
|
+
@patch(
|
|
744
|
+
"ocpp.rfid.irq_wiring_check.check_irq_pin",
|
|
745
|
+
return_value={"error": "no scanner detected"},
|
|
746
|
+
)
|
|
747
|
+
def test_detect_scanner_assumed_with_lock(self, mock_check, _mock_lock, _mock_setup):
|
|
748
|
+
result = detect_scanner()
|
|
749
|
+
self.assertTrue(result["detected"])
|
|
750
|
+
self.assertTrue(result["assumed"])
|
|
751
|
+
self.assertEqual(result["reason"], "no scanner detected")
|
|
752
|
+
self.assertEqual(result["lockfile"], "/locks/rfid.lck")
|
|
753
|
+
mock_check.assert_called_once()
|
|
754
|
+
|
|
621
755
|
@patch(
|
|
622
756
|
"ocpp.rfid.detect.detect_scanner",
|
|
623
757
|
return_value={"detected": True, "irq_pin": DEFAULT_IRQ_PIN},
|
|
@@ -642,6 +776,58 @@ class RFIDDetectionScriptTests(SimpleTestCase):
|
|
|
642
776
|
self.assertIn("missing hardware", buffer.getvalue())
|
|
643
777
|
mock_detect.assert_called_once()
|
|
644
778
|
|
|
779
|
+
@patch(
|
|
780
|
+
"ocpp.rfid.detect.detect_scanner",
|
|
781
|
+
return_value={
|
|
782
|
+
"detected": True,
|
|
783
|
+
"assumed": True,
|
|
784
|
+
"reason": "no scanner detected",
|
|
785
|
+
"lockfile": "/locks/rfid.lck",
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
def test_detect_main_assumed_output(self, mock_detect):
|
|
789
|
+
buffer = io.StringIO()
|
|
790
|
+
with patch("sys.stdout", new=buffer):
|
|
791
|
+
exit_code = detect_main([])
|
|
792
|
+
self.assertEqual(exit_code, 0)
|
|
793
|
+
self.assertIn("assumed active", buffer.getvalue())
|
|
794
|
+
self.assertIn("/locks/rfid.lck", buffer.getvalue())
|
|
795
|
+
mock_detect.assert_called_once()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
class RFIDLockFileUsageTests(SimpleTestCase):
|
|
799
|
+
@patch("ocpp.rfid.background_reader.is_configured", return_value=True)
|
|
800
|
+
def test_queue_result_marks_lock(self, _mock_config):
|
|
801
|
+
with patch(
|
|
802
|
+
"ocpp.rfid.background_reader._tag_queue.get",
|
|
803
|
+
return_value={"rfid": "ABC"},
|
|
804
|
+
) as mock_get, patch(
|
|
805
|
+
"ocpp.rfid.background_reader._mark_scanner_used"
|
|
806
|
+
) as mock_mark:
|
|
807
|
+
result = background_reader.get_next_tag()
|
|
808
|
+
self.assertEqual(result["rfid"], "ABC")
|
|
809
|
+
mock_get.assert_called_once()
|
|
810
|
+
mock_mark.assert_called_once()
|
|
811
|
+
|
|
812
|
+
@patch("ocpp.rfid.background_reader.is_configured", return_value=True)
|
|
813
|
+
def test_direct_read_marks_lock(self, _mock_config):
|
|
814
|
+
with (
|
|
815
|
+
patch(
|
|
816
|
+
"ocpp.rfid.background_reader._tag_queue.get",
|
|
817
|
+
side_effect=background_reader.queue.Empty,
|
|
818
|
+
) as mock_get,
|
|
819
|
+
patch(
|
|
820
|
+
"ocpp.rfid.reader.read_rfid",
|
|
821
|
+
return_value={"rfid": "XYZ"},
|
|
822
|
+
) as mock_read,
|
|
823
|
+
patch("ocpp.rfid.background_reader._mark_scanner_used") as mock_mark,
|
|
824
|
+
):
|
|
825
|
+
result = background_reader.get_next_tag()
|
|
826
|
+
self.assertEqual(result["rfid"], "XYZ")
|
|
827
|
+
mock_get.assert_called_once()
|
|
828
|
+
mock_read.assert_called_once()
|
|
829
|
+
mock_mark.assert_called_once()
|
|
830
|
+
|
|
645
831
|
|
|
646
832
|
class RFIDLandingTests(TestCase):
|
|
647
833
|
def test_scanner_view_registered_as_landing(self):
|
|
@@ -656,7 +842,9 @@ class RFIDLandingTests(TestCase):
|
|
|
656
842
|
app = Application.objects.create(name="Ocpp")
|
|
657
843
|
module = Module.objects.create(node_role=role, application=app, path="/ocpp/")
|
|
658
844
|
module.create_landings()
|
|
659
|
-
self.assertTrue(
|
|
845
|
+
self.assertTrue(
|
|
846
|
+
module.landings.filter(path="/ocpp/rfid/validator/").exists()
|
|
847
|
+
)
|
|
660
848
|
|
|
661
849
|
|
|
662
850
|
class ScannerTemplateTests(TestCase):
|