arthexis 0.1.26__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.

ocpp/tasks.py CHANGED
@@ -1,47 +1,78 @@
1
- import base64
2
1
  import json
3
2
  import logging
4
3
  import uuid
4
+ from dataclasses import dataclass
5
5
  from datetime import date, datetime, time, timedelta
6
6
  from pathlib import Path
7
+ from typing import Iterable
7
8
 
8
9
  from asgiref.sync import async_to_sync
9
10
  from celery import shared_task
10
11
  from django.conf import settings
11
12
  from django.contrib.auth import get_user_model
12
- from django.db.models import Q
13
+ from django.db.models import Q, Prefetch
13
14
  from django.utils import timezone
14
- import requests
15
- from requests import RequestException
16
- from cryptography.hazmat.primitives import hashes
17
- from cryptography.hazmat.primitives.asymmetric import padding
15
+ from urllib.parse import quote, urlsplit, urlunsplit
16
+ from websocket import WebSocketException, create_connection
18
17
 
19
18
  from core import mailer
20
19
  from nodes.models import Node
21
20
 
22
21
  from . import store
23
22
  from .models import Charger, MeterValue, Transaction
24
- from .network import (
25
- newest_transaction_timestamp,
26
- serialize_charger_for_network,
27
- serialize_transactions_for_forwarding,
28
- )
29
-
30
23
  logger = logging.getLogger(__name__)
31
24
 
32
25
 
33
- def _sign_payload(payload_json: str, private_key) -> str | None:
34
- if not private_key:
35
- return None
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
36
72
  try:
37
- signature = private_key.sign(
38
- payload_json.encode(),
39
- padding.PKCS1v15(),
40
- hashes.SHA256(),
41
- )
42
- except Exception:
43
- return None
44
- return base64.b64encode(signature).decode()
73
+ connection.close()
74
+ except Exception: # pragma: no cover - best effort close
75
+ pass
45
76
 
46
77
 
47
78
  @shared_task
@@ -159,20 +190,15 @@ def purge_meter_values() -> int:
159
190
  purge_meter_readings = purge_meter_values
160
191
 
161
192
 
162
- @shared_task
193
+ @shared_task(rate_limit="1/10m")
163
194
  def push_forwarded_charge_points() -> int:
164
- """Push local charge point sessions to configured upstream nodes."""
195
+ """Ensure websocket connections exist for forwarded charge points."""
165
196
 
166
197
  local = Node.get_local()
167
198
  if not local:
168
199
  logger.debug("Forwarding skipped: local node not registered")
169
200
  return 0
170
201
 
171
- private_key = local.get_private_key()
172
- if private_key is None:
173
- logger.warning("Forwarding skipped: missing local node private key")
174
- return 0
175
-
176
202
  chargers_qs = (
177
203
  Charger.objects.filter(export_transactions=True, forwarded_to__isnull=False)
178
204
  .select_related("forwarded_to", "node_origin")
@@ -184,143 +210,75 @@ def push_forwarded_charge_points() -> int:
184
210
  node_filter |= Q(node_origin=local)
185
211
 
186
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
+
187
221
  if not chargers:
188
222
  return 0
189
223
 
190
- grouped: dict[Node, list[Charger]] = {}
224
+ connected = 0
225
+
191
226
  for charger in chargers:
192
227
  target = charger.forwarded_to
193
228
  if not target:
194
229
  continue
195
230
  if local.pk and target.pk == local.pk:
196
231
  continue
197
- grouped.setdefault(target, []).append(charger)
198
-
199
- if not grouped:
200
- return 0
201
-
202
- forwarded_total = 0
203
-
204
- for node, node_chargers in grouped.items():
205
- if not node_chargers:
206
- continue
207
-
208
- initializing = [ch for ch in node_chargers if ch.forwarding_watermark is None]
209
- charger_by_pk = {ch.pk: ch for ch in node_chargers}
210
- transactions_map: dict[int, list[Transaction]] = {}
211
-
212
- for charger in node_chargers:
213
- watermark = charger.forwarding_watermark
214
- if watermark is None:
215
- continue
216
- tx_queryset = (
217
- Transaction.objects.filter(charger=charger, start_time__gt=watermark)
218
- .select_related("charger")
219
- .prefetch_related("meter_values")
220
- .order_by("start_time")
221
- )
222
- txs = list(tx_queryset)
223
- if txs:
224
- transactions_map[charger.pk] = txs
225
-
226
- transaction_payload = {"chargers": [], "transactions": []}
227
- for charger_pk, txs in transactions_map.items():
228
- charger = charger_by_pk[charger_pk]
229
- transaction_payload["chargers"].append(
230
- {
231
- "charger_id": charger.charger_id,
232
- "connector_id": charger.connector_id,
233
- "require_rfid": charger.require_rfid,
234
- }
235
- )
236
- transaction_payload["transactions"].extend(
237
- serialize_transactions_for_forwarding(txs)
238
- )
239
-
240
- payload = {
241
- "requester": str(local.uuid),
242
- "requester_mac": local.mac_address,
243
- "requester_public_key": local.public_key,
244
- "chargers": [serialize_charger_for_network(ch) for ch in initializing],
245
- }
246
-
247
- has_transactions = bool(transaction_payload["transactions"])
248
- if has_transactions or payload["chargers"]:
249
- payload["transactions"] = transaction_payload
250
- else:
251
- continue
252
232
 
253
- payload_json = json.dumps(payload, separators=(",", ":"), sort_keys=True)
254
- signature = _sign_payload(payload_json, private_key)
255
- headers = {"Content-Type": "application/json"}
256
- if signature:
257
- headers["X-Signature"] = signature
258
-
259
- success = False
260
- attempted = False
261
- for url in node.iter_remote_urls("/nodes/network/chargers/forward/"):
262
- if not url:
233
+ existing = _FORWARDING_SESSIONS.get(charger.pk)
234
+ if existing and existing.node_id == getattr(target, "pk", None):
235
+ if existing.is_connected:
263
236
  continue
237
+ _close_forwarding_session(existing)
238
+ _FORWARDING_SESSIONS.pop(charger.pk, None)
264
239
 
265
- attempted = True
240
+ for url in _candidate_forwarding_urls(target, charger):
266
241
  try:
267
- response = requests.post(
268
- url, data=payload_json, headers=headers, timeout=5
269
- )
270
- except RequestException as exc:
271
- logger.warning("Failed to forward chargers to %s: %s", node, exc)
272
- continue
273
-
274
- if not response.ok:
275
- logger.warning(
276
- "Forwarding request to %s via %s returned %s",
277
- node,
242
+ connection = create_connection(
278
243
  url,
279
- response.status_code,
244
+ timeout=5,
245
+ subprotocols=["ocpp1.6"],
280
246
  )
281
- continue
282
-
283
- try:
284
- data = response.json()
285
- except ValueError:
286
- logger.warning("Invalid JSON payload received from %s", node)
287
- continue
288
-
289
- if data.get("status") != "ok":
290
- detail = data.get("detail") if isinstance(data, dict) else None
247
+ except (WebSocketException, OSError) as exc:
291
248
  logger.warning(
292
- "Forwarding rejected by %s via %s: %s",
293
- node,
249
+ "Websocket forwarding connection to %s via %s failed: %s",
250
+ target,
294
251
  url,
295
- detail or response.text or "Remote node rejected the request.",
252
+ exc,
296
253
  )
297
254
  continue
298
255
 
299
- success = True
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
+ )
300
274
  break
275
+ else:
276
+ logger.warning(
277
+ "Unable to establish forwarding websocket for charger %s",
278
+ charger.charger_id or charger.pk,
279
+ )
301
280
 
302
- if not success:
303
- if not attempted:
304
- logger.warning(
305
- "No reachable host found for %s when forwarding chargers", node
306
- )
307
- continue
308
-
309
- updates: dict[int, datetime] = {}
310
- now = timezone.now()
311
- for charger in initializing:
312
- updates[charger.pk] = now
313
- for charger_pk, txs in transactions_map.items():
314
- latest = newest_transaction_timestamp(txs)
315
- if latest:
316
- updates[charger_pk] = latest
317
-
318
- for pk, timestamp in updates.items():
319
- Charger.objects.filter(pk=pk).update(forwarding_watermark=timestamp)
320
-
321
- forwarded_total += len(transaction_payload["transactions"])
322
-
323
- return forwarded_total
281
+ return connected
324
282
 
325
283
 
326
284
  # Backwards compatibility alias for legacy schedules
@@ -403,9 +361,15 @@ def send_daily_session_report() -> int:
403
361
  return 0
404
362
 
405
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
+ )
406
369
  transactions = list(
407
370
  Transaction.objects.filter(start_time__gte=start, start_time__lt=end)
408
371
  .select_related("charger", "account")
372
+ .prefetch_related(meter_value_prefetch)
409
373
  .order_by("start_time")
410
374
  )
411
375
  if not transactions:
ocpp/test_rfid.py CHANGED
@@ -707,11 +707,12 @@ class RFIDLastSeenTests(TestCase):
707
707
 
708
708
  class RFIDDetectionScriptTests(SimpleTestCase):
709
709
  @patch("ocpp.rfid.detect._ensure_django")
710
+ @patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
710
711
  @patch(
711
712
  "ocpp.rfid.irq_wiring_check.check_irq_pin",
712
713
  return_value={"irq_pin": DEFAULT_IRQ_PIN},
713
714
  )
714
- def test_detect_scanner_success(self, mock_check, _mock_setup):
715
+ def test_detect_scanner_success(self, mock_check, _mock_lock, _mock_setup):
715
716
  result = detect_scanner()
716
717
  self.assertEqual(
717
718
  result,
@@ -723,16 +724,34 @@ class RFIDDetectionScriptTests(SimpleTestCase):
723
724
  mock_check.assert_called_once()
724
725
 
725
726
  @patch("ocpp.rfid.detect._ensure_django")
727
+ @patch("ocpp.rfid.detect._lockfile_status", return_value=(False, None))
726
728
  @patch(
727
729
  "ocpp.rfid.irq_wiring_check.check_irq_pin",
728
730
  return_value={"error": "no scanner detected"},
729
731
  )
730
- def test_detect_scanner_failure(self, mock_check, _mock_setup):
732
+ def test_detect_scanner_failure(self, mock_check, _mock_lock, _mock_setup):
731
733
  result = detect_scanner()
732
734
  self.assertFalse(result["detected"])
733
735
  self.assertEqual(result["reason"], "no scanner detected")
734
736
  mock_check.assert_called_once()
735
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
+
736
755
  @patch(
737
756
  "ocpp.rfid.detect.detect_scanner",
738
757
  return_value={"detected": True, "irq_pin": DEFAULT_IRQ_PIN},
@@ -757,6 +776,58 @@ class RFIDDetectionScriptTests(SimpleTestCase):
757
776
  self.assertIn("missing hardware", buffer.getvalue())
758
777
  mock_detect.assert_called_once()
759
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
+
760
831
 
761
832
  class RFIDLandingTests(TestCase):
762
833
  def test_scanner_view_registered_as_landing(self):