arthexis 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of arthexis might be problematic. Click here for more details.

Files changed (54) hide show
  1. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/METADATA +36 -26
  2. arthexis-0.1.12.dist-info/RECORD +102 -0
  3. config/context_processors.py +1 -0
  4. config/settings.py +31 -5
  5. config/urls.py +5 -4
  6. core/admin.py +430 -90
  7. core/apps.py +48 -2
  8. core/backends.py +38 -0
  9. core/environment.py +23 -5
  10. core/mailer.py +3 -1
  11. core/models.py +303 -31
  12. core/reference_utils.py +20 -9
  13. core/release.py +4 -0
  14. core/sigil_builder.py +7 -2
  15. core/sigil_resolver.py +35 -4
  16. core/system.py +250 -1
  17. core/tasks.py +92 -40
  18. core/temp_passwords.py +181 -0
  19. core/test_system_info.py +62 -2
  20. core/tests.py +169 -3
  21. core/user_data.py +51 -8
  22. core/views.py +371 -20
  23. nodes/admin.py +453 -8
  24. nodes/backends.py +21 -6
  25. nodes/dns.py +203 -0
  26. nodes/feature_checks.py +133 -0
  27. nodes/models.py +374 -31
  28. nodes/reports.py +411 -0
  29. nodes/tests.py +677 -38
  30. nodes/utils.py +32 -0
  31. nodes/views.py +14 -0
  32. ocpp/admin.py +278 -15
  33. ocpp/consumers.py +517 -16
  34. ocpp/evcs_discovery.py +158 -0
  35. ocpp/models.py +237 -4
  36. ocpp/reference_utils.py +42 -0
  37. ocpp/simulator.py +321 -22
  38. ocpp/store.py +110 -2
  39. ocpp/test_rfid.py +169 -7
  40. ocpp/tests.py +819 -6
  41. ocpp/transactions_io.py +17 -3
  42. ocpp/views.py +233 -19
  43. pages/admin.py +144 -4
  44. pages/context_processors.py +21 -7
  45. pages/defaults.py +13 -0
  46. pages/forms.py +38 -0
  47. pages/models.py +189 -15
  48. pages/tests.py +281 -8
  49. pages/urls.py +4 -0
  50. pages/views.py +137 -21
  51. arthexis-0.1.10.dist-info/RECORD +0 -95
  52. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/WHEEL +0 -0
  53. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/licenses/LICENSE +0 -0
  54. {arthexis-0.1.10.dist-info → arthexis-0.1.12.dist-info}/top_level.txt +0 -0
nodes/utils.py CHANGED
@@ -2,6 +2,8 @@ from datetime import datetime
2
2
  from pathlib import Path
3
3
  import hashlib
4
4
  import logging
5
+ import shutil
6
+ import subprocess
5
7
 
6
8
  from django.conf import settings
7
9
  from selenium import webdriver
@@ -11,6 +13,7 @@ from selenium.common.exceptions import WebDriverException
11
13
  from .models import ContentSample
12
14
 
13
15
  SCREENSHOT_DIR = settings.LOG_DIR / "screenshots"
16
+ CAMERA_DIR = settings.LOG_DIR / "camera"
14
17
  logger = logging.getLogger(__name__)
15
18
 
16
19
 
@@ -46,6 +49,35 @@ def capture_screenshot(url: str, cookies=None) -> Path:
46
49
  raise RuntimeError(f"Screenshot capture failed: {exc}") from exc
47
50
 
48
51
 
52
+ def capture_rpi_snapshot(timeout: int = 10) -> Path:
53
+ """Capture a snapshot using the Raspberry Pi camera stack."""
54
+
55
+ tool_path = shutil.which("rpicam-still")
56
+ if not tool_path:
57
+ raise RuntimeError("rpicam-still is not available")
58
+ CAMERA_DIR.mkdir(parents=True, exist_ok=True)
59
+ filename = CAMERA_DIR / f"{datetime.utcnow():%Y%m%d%H%M%S}.jpg"
60
+ try:
61
+ result = subprocess.run(
62
+ [tool_path, "-o", str(filename), "-t", "1"],
63
+ capture_output=True,
64
+ text=True,
65
+ check=False,
66
+ timeout=timeout,
67
+ )
68
+ except Exception as exc: # pragma: no cover - depends on camera stack
69
+ logger.error("Failed to invoke %s: %s", tool_path, exc)
70
+ raise RuntimeError(f"Snapshot capture failed: {exc}") from exc
71
+ if result.returncode != 0:
72
+ error = (result.stderr or result.stdout or "Snapshot capture failed").strip()
73
+ logger.error("rpicam-still exited with %s: %s", result.returncode, error)
74
+ raise RuntimeError(error)
75
+ if not filename.exists():
76
+ logger.error("Snapshot file %s was not created", filename)
77
+ raise RuntimeError("Snapshot capture failed")
78
+ return filename
79
+
80
+
49
81
  def save_screenshot(path: Path, node=None, method: str = "", transaction_uuid=None):
50
82
  """Save screenshot file info if not already recorded.
51
83
 
nodes/views.py CHANGED
@@ -204,6 +204,15 @@ def register_node(request):
204
204
  signature = data.get("signature")
205
205
  installed_version = data.get("installed_version")
206
206
  installed_revision = data.get("installed_revision")
207
+ relation_present = False
208
+ if hasattr(data, "getlist"):
209
+ relation_present = "current_relation" in data
210
+ else:
211
+ relation_present = "current_relation" in data
212
+ raw_relation = data.get("current_relation")
213
+ relation_value = (
214
+ Node.normalize_relation(raw_relation) if relation_present else None
215
+ )
207
216
 
208
217
  if not hostname or not address or not mac_address:
209
218
  response = JsonResponse(
@@ -242,6 +251,8 @@ def register_node(request):
242
251
  defaults["installed_version"] = str(installed_version)[:20]
243
252
  if installed_revision is not None:
244
253
  defaults["installed_revision"] = str(installed_revision)[:40]
254
+ if relation_value is not None:
255
+ defaults["current_relation"] = relation_value
245
256
 
246
257
  node, created = Node.objects.get_or_create(
247
258
  mac_address=mac_address,
@@ -265,6 +276,9 @@ def register_node(request):
265
276
  node.installed_revision = str(installed_revision)[:40]
266
277
  if "installed_revision" not in update_fields:
267
278
  update_fields.append("installed_revision")
279
+ if relation_value is not None and node.current_relation != relation_value:
280
+ node.current_relation = relation_value
281
+ update_fields.append("current_relation")
268
282
  node.save(update_fields=update_fields)
269
283
  current_version = (node.installed_version or "").strip()
270
284
  current_revision = (node.installed_revision or "").strip()
ocpp/admin.py CHANGED
@@ -11,12 +11,16 @@ from django.urls import path
11
11
  from django.http import HttpResponse, HttpResponseRedirect
12
12
  from django.template.response import TemplateResponse
13
13
 
14
+ import uuid
15
+ from asgiref.sync import async_to_sync
16
+
14
17
  from .models import (
15
18
  Charger,
16
19
  Simulator,
17
20
  MeterValue,
18
21
  Transaction,
19
22
  Location,
23
+ DataTransferMessage,
20
24
  )
21
25
  from .simulator import ChargePointSimulator
22
26
  from . import store
@@ -108,6 +112,44 @@ class LocationAdmin(EntityModelAdmin):
108
112
  change_form_template = "admin/ocpp/location/change_form.html"
109
113
 
110
114
 
115
+ @admin.register(DataTransferMessage)
116
+ class DataTransferMessageAdmin(admin.ModelAdmin):
117
+ list_display = (
118
+ "charger",
119
+ "connector_id",
120
+ "direction",
121
+ "vendor_id",
122
+ "message_id",
123
+ "status",
124
+ "created_at",
125
+ "responded_at",
126
+ )
127
+ list_filter = ("direction", "status")
128
+ search_fields = (
129
+ "charger__charger_id",
130
+ "ocpp_message_id",
131
+ "vendor_id",
132
+ "message_id",
133
+ )
134
+ readonly_fields = (
135
+ "charger",
136
+ "connector_id",
137
+ "direction",
138
+ "ocpp_message_id",
139
+ "vendor_id",
140
+ "message_id",
141
+ "payload",
142
+ "status",
143
+ "response_data",
144
+ "error_code",
145
+ "error_description",
146
+ "error_details",
147
+ "responded_at",
148
+ "created_at",
149
+ "updated_at",
150
+ )
151
+
152
+
111
153
  @admin.register(Charger)
112
154
  class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
113
155
  fieldsets = (
@@ -122,6 +164,13 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
122
164
  "last_path",
123
165
  "last_heartbeat",
124
166
  "last_meter_values",
167
+ )
168
+ },
169
+ ),
170
+ (
171
+ "Firmware",
172
+ {
173
+ "fields": (
125
174
  "firmware_status",
126
175
  "firmware_status_info",
127
176
  "firmware_timestamp",
@@ -138,6 +187,20 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
138
187
  )
139
188
  },
140
189
  ),
190
+ (
191
+ "Availability",
192
+ {
193
+ "fields": (
194
+ "availability_state",
195
+ "availability_state_updated_at",
196
+ "availability_requested_state",
197
+ "availability_requested_at",
198
+ "availability_request_status",
199
+ "availability_request_status_at",
200
+ "availability_request_details",
201
+ )
202
+ },
203
+ ),
141
204
  (
142
205
  "Configuration",
143
206
  {"fields": ("public_display", "require_rfid")},
@@ -148,6 +211,13 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
148
211
  "fields": ("reference",),
149
212
  },
150
213
  ),
214
+ (
215
+ "Owner",
216
+ {
217
+ "fields": ("owner_users", "owner_groups"),
218
+ "classes": ("collapse",),
219
+ },
220
+ ),
151
221
  )
152
222
  readonly_fields = (
153
223
  "last_heartbeat",
@@ -155,16 +225,21 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
155
225
  "firmware_status",
156
226
  "firmware_status_info",
157
227
  "firmware_timestamp",
228
+ "availability_state",
229
+ "availability_state_updated_at",
230
+ "availability_requested_state",
231
+ "availability_requested_at",
232
+ "availability_request_status",
233
+ "availability_request_status_at",
234
+ "availability_request_details",
158
235
  )
159
236
  list_display = (
160
237
  "charger_id",
161
- "connector_id",
238
+ "connector_number",
162
239
  "location_name",
163
240
  "require_rfid_display",
164
241
  "public_display",
165
242
  "last_heartbeat",
166
- "firmware_status",
167
- "firmware_timestamp",
168
243
  "session_kw",
169
244
  "total_kw_display",
170
245
  "page_link",
@@ -172,7 +247,16 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
172
247
  "status_link",
173
248
  )
174
249
  search_fields = ("charger_id", "connector_id", "location__name")
175
- actions = ["purge_data", "delete_selected"]
250
+ filter_horizontal = ("owner_users", "owner_groups")
251
+ actions = [
252
+ "purge_data",
253
+ "fetch_cp_configuration",
254
+ "change_availability_operative",
255
+ "change_availability_inoperative",
256
+ "set_availability_state_operative",
257
+ "set_availability_state_inoperative",
258
+ "delete_selected",
259
+ ]
176
260
 
177
261
  def get_view_on_site_url(self, obj=None):
178
262
  return obj.get_absolute_url() if obj else None
@@ -215,6 +299,12 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
215
299
  def get_log_identifier(self, obj):
216
300
  return store.identity_key(obj.charger_id, obj.connector_id)
217
301
 
302
+ def connector_number(self, obj):
303
+ return obj.connector_id if obj.connector_id is not None else ""
304
+
305
+ connector_number.short_description = "#"
306
+ connector_number.admin_order_field = "connector_id"
307
+
218
308
  def status_link(self, obj):
219
309
  from django.utils.html import format_html
220
310
  from django.urls import reverse
@@ -223,7 +313,8 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
223
313
  "charger-status-connector",
224
314
  args=[obj.charger_id, obj.connector_slug],
225
315
  )
226
- return format_html('<a href="{}" target="_blank">status</a>', url)
316
+ label = (obj.last_status or "status").strip() or "status"
317
+ return format_html('<a href="{}" target="_blank">{}</a>', url, label)
227
318
 
228
319
  status_link.short_description = "Status"
229
320
 
@@ -239,6 +330,151 @@ class ChargerAdmin(LogViewAdminMixin, EntityModelAdmin):
239
330
 
240
331
  purge_data.short_description = "Purge data"
241
332
 
333
+ @admin.action(description="Fetch CP configuration")
334
+ def fetch_cp_configuration(self, request, queryset):
335
+ fetched = 0
336
+ for charger in queryset:
337
+ connector_value = charger.connector_id
338
+ ws = store.get_connection(charger.charger_id, connector_value)
339
+ if ws is None:
340
+ self.message_user(
341
+ request,
342
+ f"{charger}: no active connection",
343
+ level=messages.ERROR,
344
+ )
345
+ continue
346
+ message_id = uuid.uuid4().hex
347
+ payload = {}
348
+ msg = json.dumps([2, message_id, "GetConfiguration", payload])
349
+ try:
350
+ async_to_sync(ws.send)(msg)
351
+ except Exception as exc: # pragma: no cover - network error
352
+ self.message_user(
353
+ request,
354
+ f"{charger}: failed to send GetConfiguration ({exc})",
355
+ level=messages.ERROR,
356
+ )
357
+ continue
358
+ log_key = store.identity_key(charger.charger_id, connector_value)
359
+ store.add_log(log_key, f"< {msg}", log_type="charger")
360
+ store.register_pending_call(
361
+ message_id,
362
+ {
363
+ "action": "GetConfiguration",
364
+ "charger_id": charger.charger_id,
365
+ "connector_id": connector_value,
366
+ "log_key": log_key,
367
+ "requested_at": timezone.now(),
368
+ },
369
+ )
370
+ store.schedule_call_timeout(
371
+ message_id,
372
+ timeout=5.0,
373
+ action="GetConfiguration",
374
+ log_key=log_key,
375
+ message=(
376
+ "GetConfiguration timed out: charger did not respond"
377
+ " (operation may not be supported)"
378
+ ),
379
+ )
380
+ fetched += 1
381
+ if fetched:
382
+ self.message_user(
383
+ request,
384
+ f"Requested configuration from {fetched} charger(s)",
385
+ )
386
+
387
+ def _dispatch_change_availability(self, request, queryset, availability_type: str):
388
+ sent = 0
389
+ for charger in queryset:
390
+ connector_value = charger.connector_id
391
+ ws = store.get_connection(charger.charger_id, connector_value)
392
+ if ws is None:
393
+ self.message_user(
394
+ request,
395
+ f"{charger}: no active connection",
396
+ level=messages.ERROR,
397
+ )
398
+ continue
399
+ connector_id = connector_value if connector_value is not None else 0
400
+ message_id = uuid.uuid4().hex
401
+ payload = {"connectorId": connector_id, "type": availability_type}
402
+ msg = json.dumps([2, message_id, "ChangeAvailability", payload])
403
+ try:
404
+ async_to_sync(ws.send)(msg)
405
+ except Exception as exc: # pragma: no cover - network error
406
+ self.message_user(
407
+ request,
408
+ f"{charger}: failed to send ChangeAvailability ({exc})",
409
+ level=messages.ERROR,
410
+ )
411
+ continue
412
+ log_key = store.identity_key(charger.charger_id, connector_value)
413
+ store.add_log(log_key, f"< {msg}", log_type="charger")
414
+ timestamp = timezone.now()
415
+ store.register_pending_call(
416
+ message_id,
417
+ {
418
+ "action": "ChangeAvailability",
419
+ "charger_id": charger.charger_id,
420
+ "connector_id": connector_value,
421
+ "availability_type": availability_type,
422
+ "requested_at": timestamp,
423
+ },
424
+ )
425
+ updates = {
426
+ "availability_requested_state": availability_type,
427
+ "availability_requested_at": timestamp,
428
+ "availability_request_status": "",
429
+ "availability_request_status_at": None,
430
+ "availability_request_details": "",
431
+ }
432
+ Charger.objects.filter(pk=charger.pk).update(**updates)
433
+ for field, value in updates.items():
434
+ setattr(charger, field, value)
435
+ sent += 1
436
+ if sent:
437
+ self.message_user(
438
+ request,
439
+ f"Sent ChangeAvailability ({availability_type}) to {sent} charger(s)",
440
+ )
441
+
442
+ @admin.action(description="Set availability to Operative")
443
+ def change_availability_operative(self, request, queryset):
444
+ self._dispatch_change_availability(request, queryset, "Operative")
445
+
446
+ @admin.action(description="Set availability to Inoperative")
447
+ def change_availability_inoperative(self, request, queryset):
448
+ self._dispatch_change_availability(request, queryset, "Inoperative")
449
+
450
+ def _set_availability_state(
451
+ self, request, queryset, availability_state: str
452
+ ) -> None:
453
+ timestamp = timezone.now()
454
+ updated = 0
455
+ for charger in queryset:
456
+ updates = {
457
+ "availability_state": availability_state,
458
+ "availability_state_updated_at": timestamp,
459
+ }
460
+ Charger.objects.filter(pk=charger.pk).update(**updates)
461
+ for field, value in updates.items():
462
+ setattr(charger, field, value)
463
+ updated += 1
464
+ if updated:
465
+ self.message_user(
466
+ request,
467
+ f"Updated availability to {availability_state} for {updated} charger(s)",
468
+ )
469
+
470
+ @admin.action(description="Mark availability as Operative")
471
+ def set_availability_state_operative(self, request, queryset):
472
+ self._set_availability_state(request, queryset, "Operative")
473
+
474
+ @admin.action(description="Mark availability as Inoperative")
475
+ def set_availability_state_inoperative(self, request, queryset):
476
+ self._set_availability_state(request, queryset, "Inoperative")
477
+
242
478
  def delete_queryset(self, request, queryset):
243
479
  for obj in queryset:
244
480
  obj.delete()
@@ -270,15 +506,36 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
270
506
  "running",
271
507
  "log_link",
272
508
  )
273
- fields = (
274
- "name",
275
- "cp_path",
276
- ("host", "ws_port"),
277
- "rfid",
278
- ("duration", "interval", "pre_charge_delay"),
279
- "kw_max",
280
- ("repeat", "door_open"),
281
- ("username", "password"),
509
+ fieldsets = (
510
+ (
511
+ None,
512
+ {
513
+ "fields": (
514
+ "name",
515
+ "cp_path",
516
+ ("host", "ws_port"),
517
+ "rfid",
518
+ ("duration", "interval", "pre_charge_delay"),
519
+ "kw_max",
520
+ ("repeat", "door_open"),
521
+ ("username", "password"),
522
+ )
523
+ },
524
+ ),
525
+ (
526
+ "Configuration",
527
+ {
528
+ "fields": (
529
+ "configuration_keys",
530
+ "configuration_unknown_keys",
531
+ ),
532
+ "classes": ("collapse",),
533
+ "description": (
534
+ "Provide JSON lists for configurationKey entries and "
535
+ "unknownKey values returned by GetConfiguration."
536
+ ),
537
+ },
538
+ ),
282
539
  )
283
540
  actions = ("start_simulator", "stop_simulator", "send_open_door")
284
541
 
@@ -382,7 +639,13 @@ class SimulatorAdmin(LogViewAdminMixin, EntityModelAdmin):
382
639
  if sim:
383
640
  await sim.stop()
384
641
 
385
- asyncio.get_event_loop().create_task(_stop(list(queryset)))
642
+ objs = list(queryset)
643
+ try:
644
+ loop = asyncio.get_running_loop()
645
+ except RuntimeError:
646
+ asyncio.run(_stop(objs))
647
+ else:
648
+ loop.create_task(_stop(objs))
386
649
  self.message_user(request, "Stopping simulators")
387
650
 
388
651
  stop_simulator.short_description = "Stop selected simulators"