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.
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/METADATA +16 -11
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/RECORD +39 -38
- config/settings.py +7 -1
- config/settings_helpers.py +176 -1
- config/urls.py +18 -2
- core/admin.py +265 -23
- core/apps.py +6 -2
- core/celery_utils.py +73 -0
- core/models.py +307 -63
- core/system.py +17 -2
- core/tasks.py +304 -129
- core/test_system_info.py +43 -5
- core/tests.py +202 -2
- core/user_data.py +52 -19
- core/views.py +70 -3
- nodes/admin.py +348 -3
- nodes/apps.py +1 -1
- nodes/feature_checks.py +30 -0
- nodes/models.py +146 -18
- nodes/tasks.py +1 -1
- nodes/tests.py +181 -48
- nodes/views.py +148 -3
- ocpp/admin.py +1001 -10
- ocpp/consumers.py +572 -7
- ocpp/models.py +499 -33
- ocpp/store.py +406 -40
- ocpp/tasks.py +109 -145
- ocpp/test_rfid.py +73 -2
- ocpp/tests.py +982 -90
- ocpp/urls.py +5 -0
- ocpp/views.py +172 -70
- pages/context_processors.py +2 -0
- pages/models.py +9 -0
- pages/tests.py +166 -18
- pages/urls.py +1 -0
- pages/views.py +66 -3
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/WHEEL +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.26.dist-info → arthexis-0.1.28.dist-info}/top_level.txt +0 -0
ocpp/store.py
CHANGED
|
@@ -3,11 +3,18 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
from collections import deque
|
|
8
|
+
from dataclasses import dataclass
|
|
6
9
|
from datetime import datetime, timezone
|
|
7
10
|
import json
|
|
11
|
+
import os
|
|
8
12
|
from pathlib import Path
|
|
9
13
|
import re
|
|
10
14
|
import threading
|
|
15
|
+
import heapq
|
|
16
|
+
import itertools
|
|
17
|
+
from typing import Iterable, Iterator
|
|
11
18
|
|
|
12
19
|
from core.log_paths import select_log_dir
|
|
13
20
|
|
|
@@ -19,7 +26,10 @@ MAX_CONNECTIONS_PER_IP = 2
|
|
|
19
26
|
|
|
20
27
|
connections: dict[str, object] = {}
|
|
21
28
|
transactions: dict[str, object] = {}
|
|
22
|
-
|
|
29
|
+
# Maximum number of recent log entries to keep in memory per identity.
|
|
30
|
+
MAX_IN_MEMORY_LOG_ENTRIES = 1000
|
|
31
|
+
|
|
32
|
+
logs: dict[str, dict[str, deque[str]]] = {"charger": {}, "simulator": {}}
|
|
23
33
|
# store per charger session logs before they are flushed to disk
|
|
24
34
|
history: dict[str, dict[str, object]] = {}
|
|
25
35
|
simulators = {}
|
|
@@ -28,6 +38,7 @@ pending_calls: dict[str, dict[str, object]] = {}
|
|
|
28
38
|
_pending_call_events: dict[str, threading.Event] = {}
|
|
29
39
|
_pending_call_results: dict[str, dict[str, object]] = {}
|
|
30
40
|
_pending_call_lock = threading.Lock()
|
|
41
|
+
_pending_call_handles: dict[str, asyncio.TimerHandle] = {}
|
|
31
42
|
triggered_followups: dict[str, list[dict[str, object]]] = {}
|
|
32
43
|
|
|
33
44
|
# mapping of charger id / cp_path to friendly names used for log files
|
|
@@ -42,6 +53,20 @@ LOCK_DIR.mkdir(exist_ok=True)
|
|
|
42
53
|
SESSION_LOCK = LOCK_DIR / "charging.lck"
|
|
43
54
|
_lock_task: asyncio.Task | None = None
|
|
44
55
|
|
|
56
|
+
_scheduler_loop: asyncio.AbstractEventLoop | None = None
|
|
57
|
+
_scheduler_thread: threading.Thread | None = None
|
|
58
|
+
_scheduler_lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
SESSION_LOG_BUFFER_LIMIT = 16
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class LogEntry:
|
|
65
|
+
"""Structured log entry returned by :func:`iter_log_entries`."""
|
|
66
|
+
|
|
67
|
+
timestamp: datetime
|
|
68
|
+
text: str
|
|
69
|
+
|
|
45
70
|
|
|
46
71
|
def connector_slug(value: int | str | None) -> str:
|
|
47
72
|
"""Return the canonical slug for a connector value."""
|
|
@@ -202,13 +227,20 @@ def register_pending_call(message_id: str, metadata: dict[str, object]) -> None:
|
|
|
202
227
|
event = threading.Event()
|
|
203
228
|
_pending_call_events[message_id] = event
|
|
204
229
|
_pending_call_results.pop(message_id, None)
|
|
230
|
+
handle = _pending_call_handles.pop(message_id, None)
|
|
231
|
+
if handle:
|
|
232
|
+
_cancel_timer_handle(handle)
|
|
205
233
|
|
|
206
234
|
|
|
207
235
|
def pop_pending_call(message_id: str) -> dict[str, object] | None:
|
|
208
236
|
"""Return and remove metadata for a previously registered call."""
|
|
209
237
|
|
|
210
238
|
with _pending_call_lock:
|
|
211
|
-
|
|
239
|
+
metadata = pending_calls.pop(message_id, None)
|
|
240
|
+
handle = _pending_call_handles.pop(message_id, None)
|
|
241
|
+
if handle:
|
|
242
|
+
_cancel_timer_handle(handle)
|
|
243
|
+
return metadata
|
|
212
244
|
|
|
213
245
|
|
|
214
246
|
def record_pending_call_result(
|
|
@@ -234,6 +266,9 @@ def record_pending_call_result(
|
|
|
234
266
|
with _pending_call_lock:
|
|
235
267
|
_pending_call_results[message_id] = result
|
|
236
268
|
event = _pending_call_events.pop(message_id, None)
|
|
269
|
+
handle = _pending_call_handles.pop(message_id, None)
|
|
270
|
+
if handle:
|
|
271
|
+
_cancel_timer_handle(handle)
|
|
237
272
|
if event:
|
|
238
273
|
event.set()
|
|
239
274
|
|
|
@@ -269,29 +304,50 @@ def schedule_call_timeout(
|
|
|
269
304
|
) -> None:
|
|
270
305
|
"""Schedule a timeout notice if a pending call is not answered."""
|
|
271
306
|
|
|
307
|
+
loop = _ensure_scheduler_loop()
|
|
308
|
+
|
|
272
309
|
def _notify() -> None:
|
|
310
|
+
target_log: str | None = None
|
|
311
|
+
entry_label: str | None = None
|
|
273
312
|
with _pending_call_lock:
|
|
313
|
+
_pending_call_handles.pop(message_id, None)
|
|
274
314
|
metadata = pending_calls.get(message_id)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
315
|
+
if not metadata:
|
|
316
|
+
return
|
|
317
|
+
if action and metadata.get("action") != action:
|
|
318
|
+
return
|
|
319
|
+
if metadata.get("timeout_notice_sent"):
|
|
320
|
+
return
|
|
321
|
+
target_log = log_key or metadata.get("log_key")
|
|
322
|
+
if not target_log:
|
|
323
|
+
metadata["timeout_notice_sent"] = True
|
|
324
|
+
return
|
|
325
|
+
entry_label = message
|
|
326
|
+
if not entry_label:
|
|
327
|
+
action_label = action or str(metadata.get("action") or "Call")
|
|
328
|
+
entry_label = f"{action_label} request timed out"
|
|
283
329
|
metadata["timeout_notice_sent"] = True
|
|
330
|
+
if target_log and entry_label:
|
|
331
|
+
add_log(target_log, entry_label, log_type=log_type)
|
|
332
|
+
|
|
333
|
+
future: concurrent.futures.Future[asyncio.TimerHandle] = concurrent.futures.Future()
|
|
334
|
+
|
|
335
|
+
def _schedule_timer() -> None:
|
|
336
|
+
try:
|
|
337
|
+
handle = loop.call_later(timeout, _notify)
|
|
338
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
339
|
+
future.set_exception(exc)
|
|
284
340
|
return
|
|
285
|
-
|
|
286
|
-
if not label:
|
|
287
|
-
action_label = action or str(metadata.get("action") or "Call")
|
|
288
|
-
label = f"{action_label} request timed out"
|
|
289
|
-
add_log(target_log, label, log_type=log_type)
|
|
290
|
-
metadata["timeout_notice_sent"] = True
|
|
341
|
+
future.set_result(handle)
|
|
291
342
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
343
|
+
loop.call_soon_threadsafe(_schedule_timer)
|
|
344
|
+
handle = future.result()
|
|
345
|
+
|
|
346
|
+
with _pending_call_lock:
|
|
347
|
+
previous = _pending_call_handles.pop(message_id, None)
|
|
348
|
+
_pending_call_handles[message_id] = handle
|
|
349
|
+
if previous:
|
|
350
|
+
_cancel_timer_handle(previous)
|
|
295
351
|
|
|
296
352
|
|
|
297
353
|
def register_triggered_followup(
|
|
@@ -342,6 +398,7 @@ def consume_triggered_followup(
|
|
|
342
398
|
def clear_pending_calls(serial: str) -> None:
|
|
343
399
|
"""Remove any pending calls associated with the provided charger id."""
|
|
344
400
|
|
|
401
|
+
to_cancel: list[asyncio.TimerHandle] = []
|
|
345
402
|
with _pending_call_lock:
|
|
346
403
|
to_remove = [
|
|
347
404
|
key
|
|
@@ -352,7 +409,52 @@ def clear_pending_calls(serial: str) -> None:
|
|
|
352
409
|
pending_calls.pop(key, None)
|
|
353
410
|
_pending_call_events.pop(key, None)
|
|
354
411
|
_pending_call_results.pop(key, None)
|
|
355
|
-
|
|
412
|
+
handle = _pending_call_handles.pop(key, None)
|
|
413
|
+
if handle:
|
|
414
|
+
to_cancel.append(handle)
|
|
415
|
+
for handle in to_cancel:
|
|
416
|
+
_cancel_timer_handle(handle)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _run_scheduler_loop(
|
|
420
|
+
loop: asyncio.AbstractEventLoop, ready: threading.Event
|
|
421
|
+
) -> None:
|
|
422
|
+
asyncio.set_event_loop(loop)
|
|
423
|
+
ready.set()
|
|
424
|
+
loop.run_forever()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _ensure_scheduler_loop() -> asyncio.AbstractEventLoop:
|
|
428
|
+
global _scheduler_loop, _scheduler_thread
|
|
429
|
+
|
|
430
|
+
loop = _scheduler_loop
|
|
431
|
+
if loop and loop.is_running():
|
|
432
|
+
return loop
|
|
433
|
+
with _scheduler_lock:
|
|
434
|
+
loop = _scheduler_loop
|
|
435
|
+
if loop and loop.is_running():
|
|
436
|
+
return loop
|
|
437
|
+
loop = asyncio.new_event_loop()
|
|
438
|
+
ready = threading.Event()
|
|
439
|
+
thread = threading.Thread(
|
|
440
|
+
target=_run_scheduler_loop,
|
|
441
|
+
args=(loop, ready),
|
|
442
|
+
name="ocpp-store-scheduler",
|
|
443
|
+
daemon=True,
|
|
444
|
+
)
|
|
445
|
+
thread.start()
|
|
446
|
+
ready.wait()
|
|
447
|
+
_scheduler_loop = loop
|
|
448
|
+
_scheduler_thread = thread
|
|
449
|
+
return loop
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _cancel_timer_handle(handle: asyncio.TimerHandle) -> None:
|
|
453
|
+
loop = _scheduler_loop
|
|
454
|
+
if loop and loop.is_running():
|
|
455
|
+
loop.call_soon_threadsafe(handle.cancel)
|
|
456
|
+
else: # pragma: no cover - loop stopped during shutdown
|
|
457
|
+
handle.cancel()
|
|
356
458
|
|
|
357
459
|
|
|
358
460
|
def reassign_identity(old_key: str, new_key: str) -> str:
|
|
@@ -433,8 +535,21 @@ def add_log(cid: str, entry: str, log_type: str = "charger") -> None:
|
|
|
433
535
|
store = logs[log_type]
|
|
434
536
|
# Store log entries under the cid as provided but allow retrieval using
|
|
435
537
|
# any casing by recording entries in a case-insensitive manner.
|
|
436
|
-
|
|
437
|
-
|
|
538
|
+
buffer = None
|
|
539
|
+
lower = cid.lower()
|
|
540
|
+
key = cid
|
|
541
|
+
for existing_key, entries in store.items():
|
|
542
|
+
if existing_key.lower() == lower:
|
|
543
|
+
key = existing_key
|
|
544
|
+
buffer = entries
|
|
545
|
+
break
|
|
546
|
+
if buffer is None:
|
|
547
|
+
buffer = deque(maxlen=MAX_IN_MEMORY_LOG_ENTRIES)
|
|
548
|
+
store[key] = buffer
|
|
549
|
+
elif buffer.maxlen != MAX_IN_MEMORY_LOG_ENTRIES:
|
|
550
|
+
buffer = deque(buffer, maxlen=MAX_IN_MEMORY_LOG_ENTRIES)
|
|
551
|
+
store[key] = buffer
|
|
552
|
+
buffer.append(entry)
|
|
438
553
|
path = _file_path(key, log_type)
|
|
439
554
|
with path.open("a", encoding="utf-8") as handle:
|
|
440
555
|
handle.write(entry + "\n")
|
|
@@ -452,10 +567,29 @@ def _session_folder(cid: str) -> Path:
|
|
|
452
567
|
def start_session_log(cid: str, tx_id: int) -> None:
|
|
453
568
|
"""Begin logging a session for the given charger and transaction id."""
|
|
454
569
|
|
|
570
|
+
existing = history.pop(cid, None)
|
|
571
|
+
if existing:
|
|
572
|
+
try:
|
|
573
|
+
_finalize_session(existing)
|
|
574
|
+
except Exception:
|
|
575
|
+
# If finalizing the previous session fails we still want to reset
|
|
576
|
+
# the session metadata so the new session can proceed.
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
start = datetime.now(timezone.utc)
|
|
580
|
+
folder = _session_folder(cid)
|
|
581
|
+
date = start.strftime("%Y%m%d")
|
|
582
|
+
filename = f"{date}_{tx_id}.json"
|
|
583
|
+
path = folder / filename
|
|
584
|
+
handle = path.open("w", encoding="utf-8")
|
|
585
|
+
handle.write("[")
|
|
455
586
|
history[cid] = {
|
|
456
587
|
"transaction": tx_id,
|
|
457
|
-
"start":
|
|
458
|
-
"
|
|
588
|
+
"start": start,
|
|
589
|
+
"path": path,
|
|
590
|
+
"handle": handle,
|
|
591
|
+
"buffer": [],
|
|
592
|
+
"first": True,
|
|
459
593
|
}
|
|
460
594
|
|
|
461
595
|
|
|
@@ -465,14 +599,19 @@ def add_session_message(cid: str, message: str) -> None:
|
|
|
465
599
|
sess = history.get(cid)
|
|
466
600
|
if not sess:
|
|
467
601
|
return
|
|
468
|
-
sess
|
|
602
|
+
buffer: list[str] = sess.setdefault("buffer", [])
|
|
603
|
+
payload = json.dumps(
|
|
469
604
|
{
|
|
470
605
|
"timestamp": datetime.now(timezone.utc)
|
|
471
606
|
.isoformat()
|
|
472
607
|
.replace("+00:00", "Z"),
|
|
473
608
|
"message": message,
|
|
474
|
-
}
|
|
609
|
+
},
|
|
610
|
+
ensure_ascii=False,
|
|
475
611
|
)
|
|
612
|
+
buffer.append(payload)
|
|
613
|
+
if len(buffer) >= SESSION_LOG_BUFFER_LIMIT:
|
|
614
|
+
_flush_session_buffer(sess)
|
|
476
615
|
|
|
477
616
|
|
|
478
617
|
def end_session_log(cid: str) -> None:
|
|
@@ -481,13 +620,42 @@ def end_session_log(cid: str) -> None:
|
|
|
481
620
|
sess = history.pop(cid, None)
|
|
482
621
|
if not sess:
|
|
483
622
|
return
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
623
|
+
_finalize_session(sess)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _flush_session_buffer(sess: dict[str, object]) -> None:
|
|
627
|
+
handle = sess.get("handle")
|
|
628
|
+
buffer = sess.get("buffer")
|
|
629
|
+
if not handle or not buffer:
|
|
630
|
+
return
|
|
631
|
+
first = bool(sess.get("first", True))
|
|
632
|
+
for raw in list(buffer):
|
|
633
|
+
if not first:
|
|
634
|
+
handle.write(",")
|
|
635
|
+
handle.write("\n ")
|
|
636
|
+
handle.write(raw)
|
|
637
|
+
first = False
|
|
638
|
+
handle.flush()
|
|
639
|
+
buffer.clear()
|
|
640
|
+
sess["first"] = first
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _finalize_session(sess: dict[str, object]) -> None:
|
|
644
|
+
handle = sess.get("handle")
|
|
645
|
+
try:
|
|
646
|
+
_flush_session_buffer(sess)
|
|
647
|
+
if handle:
|
|
648
|
+
if sess.get("first", True):
|
|
649
|
+
handle.write("]\n")
|
|
650
|
+
else:
|
|
651
|
+
handle.write("\n]\n")
|
|
652
|
+
handle.flush()
|
|
653
|
+
finally:
|
|
654
|
+
if handle:
|
|
655
|
+
try:
|
|
656
|
+
handle.close()
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
491
659
|
|
|
492
660
|
|
|
493
661
|
def _log_key_candidates(cid: str, log_type: str) -> list[str]:
|
|
@@ -546,10 +714,17 @@ def _resolve_log_identifier(cid: str, log_type: str) -> tuple[str, str | None]:
|
|
|
546
714
|
def _log_file_for_identifier(cid: str, name: str | None, log_type: str) -> Path:
|
|
547
715
|
path = _file_path(cid, log_type)
|
|
548
716
|
if not path.exists():
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
717
|
+
candidates = [_safe_name(name or cid).lower()]
|
|
718
|
+
cid_candidate = _safe_name(cid).lower()
|
|
719
|
+
if cid_candidate not in candidates:
|
|
720
|
+
candidates.append(cid_candidate)
|
|
721
|
+
for candidate in candidates:
|
|
722
|
+
target = f"{log_type}.{candidate}"
|
|
723
|
+
for file in LOG_DIR.glob(f"{log_type}.*.log"):
|
|
724
|
+
if file.stem.lower() == target:
|
|
725
|
+
path = file
|
|
726
|
+
break
|
|
727
|
+
if path.exists():
|
|
553
728
|
break
|
|
554
729
|
return path
|
|
555
730
|
|
|
@@ -559,10 +734,194 @@ def _memory_logs_for_identifier(cid: str, log_type: str) -> list[str]:
|
|
|
559
734
|
lower = cid.lower()
|
|
560
735
|
for key, entries in store.items():
|
|
561
736
|
if key.lower() == lower:
|
|
562
|
-
return entries
|
|
737
|
+
return list(entries)
|
|
563
738
|
return []
|
|
564
739
|
|
|
565
740
|
|
|
741
|
+
def _parse_log_timestamp(entry: str) -> datetime | None:
|
|
742
|
+
"""Return the parsed timestamp for a log entry, if available."""
|
|
743
|
+
|
|
744
|
+
if len(entry) < 24:
|
|
745
|
+
return None
|
|
746
|
+
try:
|
|
747
|
+
timestamp = datetime.strptime(entry[:23], "%Y-%m-%d %H:%M:%S.%f")
|
|
748
|
+
except ValueError:
|
|
749
|
+
return None
|
|
750
|
+
return timestamp.replace(tzinfo=timezone.utc)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _iter_file_lines_reverse(path: Path, *, limit: int | None = None) -> Iterator[str]:
|
|
754
|
+
"""Yield lines from ``path`` starting with the newest entries."""
|
|
755
|
+
|
|
756
|
+
if not path.exists():
|
|
757
|
+
return
|
|
758
|
+
|
|
759
|
+
chunk_size = 4096
|
|
760
|
+
remaining = limit
|
|
761
|
+
with path.open("rb") as handle:
|
|
762
|
+
handle.seek(0, os.SEEK_END)
|
|
763
|
+
position = handle.tell()
|
|
764
|
+
buffer = b""
|
|
765
|
+
while position > 0:
|
|
766
|
+
read_size = min(chunk_size, position)
|
|
767
|
+
position -= read_size
|
|
768
|
+
handle.seek(position)
|
|
769
|
+
chunk = handle.read(read_size)
|
|
770
|
+
buffer = chunk + buffer
|
|
771
|
+
lines = buffer.split(b"\n")
|
|
772
|
+
buffer = lines.pop(0)
|
|
773
|
+
for line in reversed(lines):
|
|
774
|
+
if not line:
|
|
775
|
+
continue
|
|
776
|
+
try:
|
|
777
|
+
text = line.decode("utf-8")
|
|
778
|
+
except UnicodeDecodeError:
|
|
779
|
+
text = line.decode("utf-8", errors="ignore")
|
|
780
|
+
yield text
|
|
781
|
+
if remaining is not None:
|
|
782
|
+
remaining -= 1
|
|
783
|
+
if remaining <= 0:
|
|
784
|
+
return
|
|
785
|
+
if buffer:
|
|
786
|
+
try:
|
|
787
|
+
text = buffer.decode("utf-8")
|
|
788
|
+
except UnicodeDecodeError:
|
|
789
|
+
text = buffer.decode("utf-8", errors="ignore")
|
|
790
|
+
if text:
|
|
791
|
+
yield text
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def _iter_log_entries_for_key(
|
|
795
|
+
cid: str,
|
|
796
|
+
name: str | None,
|
|
797
|
+
log_type: str,
|
|
798
|
+
*,
|
|
799
|
+
since: datetime | None = None,
|
|
800
|
+
limit: int | None = None,
|
|
801
|
+
) -> Iterator[LogEntry]:
|
|
802
|
+
"""Yield structured log entries for a specific identifier."""
|
|
803
|
+
|
|
804
|
+
yielded = 0
|
|
805
|
+
seen_for_key: set[str] = set()
|
|
806
|
+
memory_entries = _memory_logs_for_identifier(cid, log_type)
|
|
807
|
+
for entry in reversed(memory_entries):
|
|
808
|
+
if entry in seen_for_key:
|
|
809
|
+
continue
|
|
810
|
+
timestamp = _parse_log_timestamp(entry)
|
|
811
|
+
if timestamp is None:
|
|
812
|
+
continue
|
|
813
|
+
seen_for_key.add(entry)
|
|
814
|
+
yield LogEntry(timestamp=timestamp, text=entry)
|
|
815
|
+
yielded += 1
|
|
816
|
+
if since is not None and timestamp < since:
|
|
817
|
+
return
|
|
818
|
+
if limit is not None and yielded >= limit:
|
|
819
|
+
return
|
|
820
|
+
|
|
821
|
+
path = _log_file_for_identifier(cid, name, log_type)
|
|
822
|
+
file_limit = None
|
|
823
|
+
if limit is not None:
|
|
824
|
+
file_limit = max(limit - yielded, 0)
|
|
825
|
+
if file_limit == 0:
|
|
826
|
+
return
|
|
827
|
+
for entry in _iter_file_lines_reverse(path, limit=file_limit):
|
|
828
|
+
if entry in seen_for_key:
|
|
829
|
+
continue
|
|
830
|
+
timestamp = _parse_log_timestamp(entry)
|
|
831
|
+
if timestamp is None:
|
|
832
|
+
continue
|
|
833
|
+
seen_for_key.add(entry)
|
|
834
|
+
yield LogEntry(timestamp=timestamp, text=entry)
|
|
835
|
+
yielded += 1
|
|
836
|
+
if since is not None and timestamp < since:
|
|
837
|
+
return
|
|
838
|
+
if limit is not None and yielded >= limit:
|
|
839
|
+
return
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def iter_log_entries(
|
|
843
|
+
identifiers: str | Iterable[str],
|
|
844
|
+
log_type: str = "charger",
|
|
845
|
+
*,
|
|
846
|
+
since: datetime | None = None,
|
|
847
|
+
limit: int | None = None,
|
|
848
|
+
) -> Iterator[LogEntry]:
|
|
849
|
+
"""Yield log entries ordered from newest to oldest.
|
|
850
|
+
|
|
851
|
+
``identifiers`` may be a single charger identifier or an iterable of
|
|
852
|
+
identifiers. Results are de-duplicated across matching memory and file
|
|
853
|
+
sources and iteration stops once entries fall before ``since`` or ``limit``
|
|
854
|
+
is reached.
|
|
855
|
+
"""
|
|
856
|
+
|
|
857
|
+
if isinstance(identifiers, str):
|
|
858
|
+
requested: list[str] = [identifiers]
|
|
859
|
+
else:
|
|
860
|
+
requested = list(identifiers)
|
|
861
|
+
|
|
862
|
+
seen_keys: set[str] = set()
|
|
863
|
+
sources: list[tuple[str, str | None]] = []
|
|
864
|
+
for identifier in requested:
|
|
865
|
+
for key in _log_key_candidates(identifier, log_type):
|
|
866
|
+
lower_key = key.lower()
|
|
867
|
+
if lower_key in seen_keys:
|
|
868
|
+
continue
|
|
869
|
+
seen_keys.add(lower_key)
|
|
870
|
+
resolved, name = _resolve_log_identifier(key, log_type)
|
|
871
|
+
sources.append((resolved, name))
|
|
872
|
+
|
|
873
|
+
heap: list[tuple[float, int, LogEntry, Iterator[LogEntry]]] = []
|
|
874
|
+
counter = itertools.count()
|
|
875
|
+
seen_entries: set[str] = set()
|
|
876
|
+
total_yielded = 0
|
|
877
|
+
|
|
878
|
+
for resolved, name in sources:
|
|
879
|
+
iterator = _iter_log_entries_for_key(
|
|
880
|
+
resolved,
|
|
881
|
+
name,
|
|
882
|
+
log_type,
|
|
883
|
+
since=since,
|
|
884
|
+
limit=limit,
|
|
885
|
+
)
|
|
886
|
+
try:
|
|
887
|
+
entry = next(iterator)
|
|
888
|
+
except StopIteration:
|
|
889
|
+
continue
|
|
890
|
+
heapq.heappush(
|
|
891
|
+
heap,
|
|
892
|
+
(
|
|
893
|
+
-entry.timestamp.timestamp(),
|
|
894
|
+
next(counter),
|
|
895
|
+
entry,
|
|
896
|
+
iterator,
|
|
897
|
+
),
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
while heap:
|
|
901
|
+
_, _, entry, iterator = heapq.heappop(heap)
|
|
902
|
+
if entry.text not in seen_entries:
|
|
903
|
+
seen_entries.add(entry.text)
|
|
904
|
+
yield entry
|
|
905
|
+
total_yielded += 1
|
|
906
|
+
if limit is not None and total_yielded >= limit:
|
|
907
|
+
return
|
|
908
|
+
if since is not None and entry.timestamp < since:
|
|
909
|
+
return
|
|
910
|
+
try:
|
|
911
|
+
next_entry = next(iterator)
|
|
912
|
+
except StopIteration:
|
|
913
|
+
continue
|
|
914
|
+
heapq.heappush(
|
|
915
|
+
heap,
|
|
916
|
+
(
|
|
917
|
+
-next_entry.timestamp.timestamp(),
|
|
918
|
+
next(counter),
|
|
919
|
+
next_entry,
|
|
920
|
+
iterator,
|
|
921
|
+
),
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
|
|
566
925
|
def get_logs(cid: str, log_type: str = "charger") -> list[str]:
|
|
567
926
|
"""Return all log entries for the given id and type."""
|
|
568
927
|
|
|
@@ -573,14 +932,21 @@ def get_logs(cid: str, log_type: str = "charger") -> list[str]:
|
|
|
573
932
|
resolved, name = _resolve_log_identifier(key, log_type)
|
|
574
933
|
path = _log_file_for_identifier(resolved, name, log_type)
|
|
575
934
|
if path.exists() and path not in seen_paths:
|
|
576
|
-
|
|
935
|
+
if max_entries is None:
|
|
936
|
+
entries.extend(path.read_text(encoding="utf-8").splitlines())
|
|
937
|
+
else:
|
|
938
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
939
|
+
for line in handle:
|
|
940
|
+
entries.append(line.rstrip("\r\n"))
|
|
577
941
|
seen_paths.add(path)
|
|
578
942
|
memory_entries = _memory_logs_for_identifier(resolved, log_type)
|
|
579
943
|
lower_key = resolved.lower()
|
|
580
944
|
if memory_entries and lower_key not in seen_keys:
|
|
581
945
|
entries.extend(memory_entries)
|
|
582
946
|
seen_keys.add(lower_key)
|
|
583
|
-
|
|
947
|
+
if max_entries is None:
|
|
948
|
+
return entries_list
|
|
949
|
+
return list(entries_deque)
|
|
584
950
|
|
|
585
951
|
|
|
586
952
|
def clear_log(cid: str, log_type: str = "charger") -> None:
|