arthexis 0.1.9__py3-none-any.whl → 0.1.26__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 (112) hide show
  1. arthexis-0.1.26.dist-info/METADATA +272 -0
  2. arthexis-0.1.26.dist-info/RECORD +111 -0
  3. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
  4. config/__init__.py +5 -5
  5. config/active_app.py +15 -15
  6. config/asgi.py +29 -29
  7. config/auth_app.py +7 -7
  8. config/celery.py +32 -25
  9. config/context_processors.py +67 -68
  10. config/horologia_app.py +7 -7
  11. config/loadenv.py +11 -11
  12. config/logging.py +59 -48
  13. config/middleware.py +71 -25
  14. config/offline.py +49 -49
  15. config/settings.py +676 -492
  16. config/settings_helpers.py +109 -0
  17. config/urls.py +228 -159
  18. config/wsgi.py +17 -17
  19. core/admin.py +4052 -2066
  20. core/admin_history.py +50 -50
  21. core/admindocs.py +192 -151
  22. core/apps.py +350 -223
  23. core/auto_upgrade.py +72 -0
  24. core/backends.py +311 -124
  25. core/changelog.py +403 -0
  26. core/entity.py +149 -133
  27. core/environment.py +60 -43
  28. core/fields.py +168 -75
  29. core/form_fields.py +75 -0
  30. core/github_helper.py +188 -25
  31. core/github_issues.py +183 -172
  32. core/github_repos.py +72 -0
  33. core/lcd_screen.py +78 -78
  34. core/liveupdate.py +25 -25
  35. core/log_paths.py +114 -100
  36. core/mailer.py +89 -83
  37. core/middleware.py +91 -91
  38. core/models.py +5041 -2195
  39. core/notifications.py +105 -105
  40. core/public_wifi.py +267 -227
  41. core/reference_utils.py +107 -0
  42. core/release.py +940 -346
  43. core/rfid_import_export.py +113 -0
  44. core/sigil_builder.py +149 -131
  45. core/sigil_context.py +20 -20
  46. core/sigil_resolver.py +250 -284
  47. core/system.py +1425 -230
  48. core/tasks.py +538 -199
  49. core/temp_passwords.py +181 -0
  50. core/test_system_info.py +202 -43
  51. core/tests.py +2673 -1069
  52. core/tests_liveupdate.py +17 -17
  53. core/urls.py +11 -11
  54. core/user_data.py +681 -495
  55. core/views.py +2484 -789
  56. core/widgets.py +213 -51
  57. nodes/admin.py +2236 -445
  58. nodes/apps.py +98 -70
  59. nodes/backends.py +160 -53
  60. nodes/dns.py +203 -0
  61. nodes/feature_checks.py +133 -0
  62. nodes/lcd.py +165 -165
  63. nodes/models.py +2375 -870
  64. nodes/reports.py +411 -0
  65. nodes/rfid_sync.py +210 -0
  66. nodes/signals.py +18 -0
  67. nodes/tasks.py +141 -46
  68. nodes/tests.py +5045 -1489
  69. nodes/urls.py +29 -13
  70. nodes/utils.py +172 -73
  71. nodes/views.py +1768 -304
  72. ocpp/admin.py +1775 -481
  73. ocpp/apps.py +25 -25
  74. ocpp/consumers.py +1843 -630
  75. ocpp/evcs.py +844 -928
  76. ocpp/evcs_discovery.py +158 -0
  77. ocpp/models.py +1417 -640
  78. ocpp/network.py +398 -0
  79. ocpp/reference_utils.py +42 -0
  80. ocpp/routing.py +11 -9
  81. ocpp/simulator.py +745 -368
  82. ocpp/status_display.py +26 -0
  83. ocpp/store.py +603 -403
  84. ocpp/tasks.py +479 -31
  85. ocpp/test_export_import.py +131 -130
  86. ocpp/test_rfid.py +1072 -540
  87. ocpp/tests.py +5494 -2296
  88. ocpp/transactions_io.py +197 -165
  89. ocpp/urls.py +50 -50
  90. ocpp/views.py +2024 -912
  91. pages/admin.py +1123 -396
  92. pages/apps.py +45 -10
  93. pages/checks.py +40 -40
  94. pages/context_processors.py +151 -85
  95. pages/defaults.py +13 -0
  96. pages/forms.py +221 -0
  97. pages/middleware.py +213 -153
  98. pages/models.py +720 -252
  99. pages/module_defaults.py +156 -0
  100. pages/site_config.py +137 -0
  101. pages/tasks.py +74 -0
  102. pages/tests.py +4009 -1389
  103. pages/urls.py +38 -20
  104. pages/utils.py +93 -12
  105. pages/views.py +1736 -762
  106. arthexis-0.1.9.dist-info/METADATA +0 -168
  107. arthexis-0.1.9.dist-info/RECORD +0 -92
  108. core/workgroup_urls.py +0 -17
  109. core/workgroup_views.py +0 -94
  110. nodes/actions.py +0 -70
  111. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
  112. {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/top_level.txt +0 -0
core/github_issues.py CHANGED
@@ -1,172 +1,183 @@
1
- from __future__ import annotations
2
-
3
- import hashlib
4
- import logging
5
- import os
6
- from datetime import datetime, timedelta
7
- from pathlib import Path
8
- from typing import Iterable, Mapping
9
-
10
- import requests
11
-
12
- from .models import Package, PackageRelease
13
- from .release import DEFAULT_PACKAGE
14
-
15
-
16
- logger = logging.getLogger(__name__)
17
-
18
- BASE_DIR = Path(__file__).resolve().parent.parent
19
- LOCK_DIR = BASE_DIR / "locks" / "github-issues"
20
- LOCK_TTL = timedelta(hours=1)
21
- REQUEST_TIMEOUT = 10
22
-
23
-
24
- def resolve_repository() -> tuple[str, str]:
25
- """Return the ``(owner, repo)`` tuple for the active package."""
26
-
27
- package = Package.objects.filter(is_active=True).first()
28
- repository_url = (
29
- package.repository_url
30
- if package and package.repository_url
31
- else DEFAULT_PACKAGE.repository_url
32
- )
33
-
34
- owner: str
35
- repo: str
36
-
37
- if repository_url.startswith("git@"):
38
- _, _, remainder = repository_url.partition(":")
39
- path = remainder
40
- else:
41
- from urllib.parse import urlparse
42
-
43
- parsed = urlparse(repository_url)
44
- path = parsed.path
45
-
46
- path = path.strip("/")
47
- if path.endswith(".git"):
48
- path = path[:-4]
49
-
50
- segments = [segment for segment in path.split("/") if segment]
51
- if len(segments) < 2:
52
- raise ValueError(f"Invalid repository URL: {repository_url!r}")
53
-
54
- owner, repo = segments[-2], segments[-1]
55
- return owner, repo
56
-
57
-
58
- def get_github_token() -> str:
59
- """Return the configured GitHub token.
60
-
61
- Preference is given to the latest :class:`~core.models.PackageRelease`.
62
- When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
63
- """
64
-
65
- latest_release = PackageRelease.latest()
66
- if latest_release:
67
- token = latest_release.get_github_token()
68
- if token:
69
- return token
70
-
71
- try:
72
- return os.environ["GITHUB_TOKEN"]
73
- except KeyError as exc: # pragma: no cover - defensive guard
74
- raise RuntimeError("GitHub token is not configured") from exc
75
-
76
-
77
- def _ensure_lock_dir() -> None:
78
- LOCK_DIR.mkdir(parents=True, exist_ok=True)
79
-
80
-
81
- def _fingerprint_digest(fingerprint: str) -> str:
82
- return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
83
-
84
-
85
- def _fingerprint_path(fingerprint: str) -> Path:
86
- return LOCK_DIR / _fingerprint_digest(fingerprint)
87
-
88
-
89
- def _has_recent_marker(lock_path: Path) -> bool:
90
- if not lock_path.exists():
91
- return False
92
-
93
- marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
94
- lock_path.stat().st_mtime
95
- )
96
- return marker_age < LOCK_TTL
97
-
98
-
99
- def build_issue_payload(
100
- title: str,
101
- body: str,
102
- labels: Iterable[str] | None = None,
103
- fingerprint: str | None = None,
104
- ) -> Mapping[str, object] | None:
105
- """Return an API payload for GitHub issues.
106
-
107
- When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
108
- are ignored by returning ``None``. A marker is kept on disk to prevent
109
- repeated reports during the cooldown window.
110
- """
111
-
112
- payload: dict[str, object] = {"title": title, "body": body}
113
-
114
- if labels:
115
- deduped = list(dict.fromkeys(labels))
116
- if deduped:
117
- payload["labels"] = deduped
118
-
119
- if fingerprint:
120
- _ensure_lock_dir()
121
- lock_path = _fingerprint_path(fingerprint)
122
- if _has_recent_marker(lock_path):
123
- logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
124
- return None
125
-
126
- lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
127
- digest = _fingerprint_digest(fingerprint)
128
- payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
129
-
130
- return payload
131
-
132
-
133
- def create_issue(
134
- title: str,
135
- body: str,
136
- labels: Iterable[str] | None = None,
137
- fingerprint: str | None = None,
138
- ) -> requests.Response | None:
139
- """Create a GitHub issue using the configured repository and token."""
140
-
141
- payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
142
- if payload is None:
143
- return None
144
-
145
- owner, repo = resolve_repository()
146
- token = get_github_token()
147
-
148
- headers = {
149
- "Accept": "application/vnd.github+json",
150
- "Authorization": f"token {token}",
151
- "User-Agent": "arthexis-runtime-reporter",
152
- }
153
- url = f"https://api.github.com/repos/{owner}/{repo}/issues"
154
-
155
- response = requests.post(
156
- url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
157
- )
158
- if not (200 <= response.status_code < 300):
159
- logger.error(
160
- "GitHub issue creation failed with status %s: %s",
161
- response.status_code,
162
- response.text,
163
- )
164
- response.raise_for_status()
165
-
166
- logger.info(
167
- "GitHub issue created for %s/%s with status %s",
168
- owner,
169
- repo,
170
- response.status_code,
171
- )
172
- return response
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Iterable, Mapping
9
+
10
+ import requests
11
+
12
+ from .models import Package, PackageRelease
13
+ from .release import DEFAULT_PACKAGE
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ BASE_DIR = Path(__file__).resolve().parent.parent
19
+ LOCK_DIR = BASE_DIR / "locks" / "github-issues"
20
+ LOCK_TTL = timedelta(hours=1)
21
+ REQUEST_TIMEOUT = 10
22
+
23
+
24
+ def resolve_repository() -> tuple[str, str]:
25
+ """Return the ``(owner, repo)`` tuple for the active package."""
26
+
27
+ package = Package.objects.filter(is_active=True).first()
28
+
29
+ repository_url: str
30
+ if package is not None:
31
+ raw_url = getattr(package, "repository_url", "")
32
+ if raw_url is None:
33
+ cleaned_url = ""
34
+ else:
35
+ cleaned_url = str(raw_url).strip()
36
+ repository_url = cleaned_url or DEFAULT_PACKAGE.repository_url
37
+ else:
38
+ repository_url = DEFAULT_PACKAGE.repository_url
39
+
40
+ owner: str
41
+ repo: str
42
+
43
+ if repository_url.startswith("git@"):
44
+ _, _, remainder = repository_url.partition(":")
45
+ path = remainder
46
+ else:
47
+ from urllib.parse import urlparse
48
+
49
+ parsed = urlparse(repository_url)
50
+ path = parsed.path
51
+
52
+ path = path.strip("/")
53
+ if path.endswith(".git"):
54
+ path = path[:-4]
55
+
56
+ segments = [segment for segment in path.split("/") if segment]
57
+ if len(segments) < 2:
58
+ raise ValueError(f"Invalid repository URL: {repository_url!r}")
59
+
60
+ owner, repo = segments[-2], segments[-1]
61
+ return owner, repo
62
+
63
+
64
+ def get_github_token() -> str:
65
+ """Return the configured GitHub token.
66
+
67
+ Preference is given to the latest :class:`~core.models.PackageRelease`.
68
+ When unavailable, fall back to the ``GITHUB_TOKEN`` environment variable.
69
+ """
70
+
71
+ latest_release = PackageRelease.latest()
72
+ if latest_release:
73
+ token = latest_release.get_github_token()
74
+ if token is not None:
75
+ cleaned = token.strip() if isinstance(token, str) else str(token).strip()
76
+ if cleaned:
77
+ return cleaned
78
+
79
+ env_token = os.environ.get("GITHUB_TOKEN")
80
+ if env_token is not None:
81
+ cleaned = env_token.strip() if isinstance(env_token, str) else str(env_token).strip()
82
+ if cleaned:
83
+ return cleaned
84
+
85
+ raise RuntimeError("GitHub token is not configured")
86
+
87
+
88
+ def _ensure_lock_dir() -> None:
89
+ LOCK_DIR.mkdir(parents=True, exist_ok=True)
90
+
91
+
92
+ def _fingerprint_digest(fingerprint: str) -> str:
93
+ return hashlib.sha256(str(fingerprint).encode("utf-8")).hexdigest()
94
+
95
+
96
+ def _fingerprint_path(fingerprint: str) -> Path:
97
+ return LOCK_DIR / _fingerprint_digest(fingerprint)
98
+
99
+
100
+ def _has_recent_marker(lock_path: Path) -> bool:
101
+ if not lock_path.exists():
102
+ return False
103
+
104
+ marker_age = datetime.utcnow() - datetime.utcfromtimestamp(
105
+ lock_path.stat().st_mtime
106
+ )
107
+ return marker_age < LOCK_TTL
108
+
109
+
110
+ def build_issue_payload(
111
+ title: str,
112
+ body: str,
113
+ labels: Iterable[str] | None = None,
114
+ fingerprint: str | None = None,
115
+ ) -> Mapping[str, object] | None:
116
+ """Return an API payload for GitHub issues.
117
+
118
+ When ``fingerprint`` is provided, duplicate submissions within ``LOCK_TTL``
119
+ are ignored by returning ``None``. A marker is kept on disk to prevent
120
+ repeated reports during the cooldown window.
121
+ """
122
+
123
+ payload: dict[str, object] = {"title": title, "body": body}
124
+
125
+ if labels:
126
+ deduped = list(dict.fromkeys(labels))
127
+ if deduped:
128
+ payload["labels"] = deduped
129
+
130
+ if fingerprint:
131
+ _ensure_lock_dir()
132
+ lock_path = _fingerprint_path(fingerprint)
133
+ if _has_recent_marker(lock_path):
134
+ logger.info("Skipping GitHub issue for active fingerprint %s", fingerprint)
135
+ return None
136
+
137
+ lock_path.write_text(datetime.utcnow().isoformat(), encoding="utf-8")
138
+ digest = _fingerprint_digest(fingerprint)
139
+ payload["body"] = f"{body}\n\n<!-- fingerprint:{digest} -->"
140
+
141
+ return payload
142
+
143
+
144
+ def create_issue(
145
+ title: str,
146
+ body: str,
147
+ labels: Iterable[str] | None = None,
148
+ fingerprint: str | None = None,
149
+ ) -> requests.Response | None:
150
+ """Create a GitHub issue using the configured repository and token."""
151
+
152
+ payload = build_issue_payload(title, body, labels=labels, fingerprint=fingerprint)
153
+ if payload is None:
154
+ return None
155
+
156
+ owner, repo = resolve_repository()
157
+ token = get_github_token()
158
+
159
+ headers = {
160
+ "Accept": "application/vnd.github+json",
161
+ "Authorization": f"token {token}",
162
+ "User-Agent": "arthexis-runtime-reporter",
163
+ }
164
+ url = f"https://api.github.com/repos/{owner}/{repo}/issues"
165
+
166
+ response = requests.post(
167
+ url, json=payload, headers=headers, timeout=REQUEST_TIMEOUT
168
+ )
169
+ if not (200 <= response.status_code < 300):
170
+ logger.error(
171
+ "GitHub issue creation failed with status %s: %s",
172
+ response.status_code,
173
+ response.text,
174
+ )
175
+ response.raise_for_status()
176
+
177
+ logger.info(
178
+ "GitHub issue created for %s/%s with status %s",
179
+ owner,
180
+ repo,
181
+ response.status_code,
182
+ )
183
+ return response
core/github_repos.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Mapping
5
+
6
+ import requests
7
+
8
+ from .github_issues import REQUEST_TIMEOUT, get_github_token
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _build_repository_payload(
15
+ repo: str,
16
+ visibility: str,
17
+ description: str | None,
18
+ ) -> Mapping[str, object]:
19
+ payload: dict[str, object] = {"name": repo, "visibility": visibility}
20
+
21
+ if description is not None:
22
+ payload["description"] = description
23
+
24
+ return payload
25
+
26
+
27
+ def create_repository(
28
+ owner: str | None,
29
+ repo: str,
30
+ *,
31
+ visibility: str = "private",
32
+ description: str | None = None,
33
+ ) -> requests.Response:
34
+ """Create a GitHub repository for the authenticated user or organisation."""
35
+
36
+ token = get_github_token()
37
+
38
+ headers = {
39
+ "Accept": "application/vnd.github+json",
40
+ "Authorization": f"token {token}",
41
+ "User-Agent": "arthexis-runtime-reporter",
42
+ }
43
+
44
+ if owner:
45
+ url = f"https://api.github.com/orgs/{owner}/repos"
46
+ else:
47
+ url = "https://api.github.com/user/repos"
48
+
49
+ payload = _build_repository_payload(repo, visibility, description)
50
+
51
+ response = requests.post(
52
+ url,
53
+ json=payload,
54
+ headers=headers,
55
+ timeout=REQUEST_TIMEOUT,
56
+ )
57
+
58
+ if not (200 <= response.status_code < 300):
59
+ logger.error(
60
+ "GitHub repository creation failed with status %s: %s",
61
+ response.status_code,
62
+ response.text,
63
+ )
64
+ response.raise_for_status()
65
+
66
+ logger.info(
67
+ "GitHub repository created for %s with status %s",
68
+ owner or "authenticated user",
69
+ response.status_code,
70
+ )
71
+
72
+ return response
core/lcd_screen.py CHANGED
@@ -1,78 +1,78 @@
1
- """Standalone LCD screen updater.
2
-
3
- The script polls ``locks/lcd_screen.lck`` for up to two lines of text and
4
- writes them to the attached LCD1602 display. If either line exceeds 16
5
- characters the text scrolls horizontally. A third line in the lock file
6
- can define the scroll speed in milliseconds per character (default 1000
7
- ms).
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import logging
13
- import time
14
- from pathlib import Path
15
-
16
- from nodes.lcd import CharLCD1602, LCDUnavailableError
17
-
18
- logger = logging.getLogger(__name__)
19
-
20
- LOCK_FILE = Path(__file__).resolve().parents[1] / "locks" / "lcd_screen.lck"
21
- DEFAULT_SCROLL_MS = 1000
22
-
23
-
24
- def _read_lock_file() -> tuple[str, str, int]:
25
- try:
26
- lines = LOCK_FILE.read_text(encoding="utf-8").splitlines()
27
- except FileNotFoundError:
28
- return "", "", DEFAULT_SCROLL_MS
29
- line1 = lines[0][:64] if len(lines) > 0 else ""
30
- line2 = lines[1][:64] if len(lines) > 1 else ""
31
- try:
32
- speed = int(lines[2]) if len(lines) > 2 else DEFAULT_SCROLL_MS
33
- except ValueError:
34
- speed = DEFAULT_SCROLL_MS
35
- return line1, line2, speed
36
-
37
-
38
- def _display(lcd: CharLCD1602, line1: str, line2: str, scroll_ms: int) -> None:
39
- scroll_sec = max(scroll_ms, 0) / 1000.0
40
- text1 = line1[:64]
41
- text2 = line2[:64]
42
- pad1 = text1 + " " * 16 if len(text1) > 16 else text1.ljust(16)
43
- pad2 = text2 + " " * 16 if len(text2) > 16 else text2.ljust(16)
44
- steps = max(len(pad1) - 15, len(pad2) - 15)
45
- for i in range(steps):
46
- segment1 = pad1[i : i + 16]
47
- segment2 = pad2[i : i + 16]
48
- lcd.write(0, 0, segment1.ljust(16))
49
- lcd.write(0, 1, segment2.ljust(16))
50
- time.sleep(scroll_sec)
51
-
52
-
53
- def main() -> None: # pragma: no cover - hardware dependent
54
- lcd = None
55
- last_mtime = 0.0
56
- while True:
57
- try:
58
- if LOCK_FILE.exists():
59
- mtime = LOCK_FILE.stat().st_mtime
60
- if mtime != last_mtime or lcd is None:
61
- line1, line2, speed = _read_lock_file()
62
- if lcd is None:
63
- lcd = CharLCD1602()
64
- lcd.init_lcd()
65
- lcd.clear()
66
- _display(lcd, line1, line2, speed)
67
- last_mtime = mtime
68
- except LCDUnavailableError as exc:
69
- logger.warning("LCD unavailable: %s", exc)
70
- lcd = None
71
- except Exception as exc:
72
- logger.warning("LCD update failed: %s", exc)
73
- lcd = None
74
- time.sleep(0.5)
75
-
76
-
77
- if __name__ == "__main__": # pragma: no cover - script entry point
78
- main()
1
+ """Standalone LCD screen updater.
2
+
3
+ The script polls ``locks/lcd_screen.lck`` for up to two lines of text and
4
+ writes them to the attached LCD1602 display. If either line exceeds 16
5
+ characters the text scrolls horizontally. A third line in the lock file
6
+ can define the scroll speed in milliseconds per character (default 1000
7
+ ms).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from nodes.lcd import CharLCD1602, LCDUnavailableError
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ LOCK_FILE = Path(__file__).resolve().parents[1] / "locks" / "lcd_screen.lck"
21
+ DEFAULT_SCROLL_MS = 1000
22
+
23
+
24
+ def _read_lock_file() -> tuple[str, str, int]:
25
+ try:
26
+ lines = LOCK_FILE.read_text(encoding="utf-8").splitlines()
27
+ except FileNotFoundError:
28
+ return "", "", DEFAULT_SCROLL_MS
29
+ line1 = lines[0][:64] if len(lines) > 0 else ""
30
+ line2 = lines[1][:64] if len(lines) > 1 else ""
31
+ try:
32
+ speed = int(lines[2]) if len(lines) > 2 else DEFAULT_SCROLL_MS
33
+ except ValueError:
34
+ speed = DEFAULT_SCROLL_MS
35
+ return line1, line2, speed
36
+
37
+
38
+ def _display(lcd: CharLCD1602, line1: str, line2: str, scroll_ms: int) -> None:
39
+ scroll_sec = max(scroll_ms, 0) / 1000.0
40
+ text1 = line1[:64]
41
+ text2 = line2[:64]
42
+ pad1 = text1 + " " * 16 if len(text1) > 16 else text1.ljust(16)
43
+ pad2 = text2 + " " * 16 if len(text2) > 16 else text2.ljust(16)
44
+ steps = max(len(pad1) - 15, len(pad2) - 15)
45
+ for i in range(steps):
46
+ segment1 = pad1[i : i + 16]
47
+ segment2 = pad2[i : i + 16]
48
+ lcd.write(0, 0, segment1.ljust(16))
49
+ lcd.write(0, 1, segment2.ljust(16))
50
+ time.sleep(scroll_sec)
51
+
52
+
53
+ def main() -> None: # pragma: no cover - hardware dependent
54
+ lcd = None
55
+ last_mtime = 0.0
56
+ while True:
57
+ try:
58
+ if LOCK_FILE.exists():
59
+ mtime = LOCK_FILE.stat().st_mtime
60
+ if mtime != last_mtime or lcd is None:
61
+ line1, line2, speed = _read_lock_file()
62
+ if lcd is None:
63
+ lcd = CharLCD1602()
64
+ lcd.init_lcd()
65
+ lcd.clear()
66
+ _display(lcd, line1, line2, speed)
67
+ last_mtime = mtime
68
+ except LCDUnavailableError as exc:
69
+ logger.warning("LCD unavailable: %s", exc)
70
+ lcd = None
71
+ except Exception as exc:
72
+ logger.warning("LCD update failed: %s", exc)
73
+ lcd = None
74
+ time.sleep(0.5)
75
+
76
+
77
+ if __name__ == "__main__": # pragma: no cover - script entry point
78
+ main()
core/liveupdate.py CHANGED
@@ -1,25 +1,25 @@
1
- from functools import wraps
2
-
3
-
4
- def live_update(interval=5):
5
- """Decorator to mark function-based views for automatic refresh."""
6
-
7
- def decorator(view):
8
- @wraps(view)
9
- def wrapped(request, *args, **kwargs):
10
- setattr(request, "live_update_interval", interval)
11
- return view(request, *args, **kwargs)
12
-
13
- return wrapped
14
-
15
- return decorator
16
-
17
-
18
- class LiveUpdateMixin:
19
- """Mixin to enable automatic refresh for class-based views."""
20
-
21
- live_update_interval = 5
22
-
23
- def dispatch(self, request, *args, **kwargs):
24
- setattr(request, "live_update_interval", self.live_update_interval)
25
- return super().dispatch(request, *args, **kwargs)
1
+ from functools import wraps
2
+
3
+
4
+ def live_update(interval=5):
5
+ """Decorator to mark function-based views for automatic refresh."""
6
+
7
+ def decorator(view):
8
+ @wraps(view)
9
+ def wrapped(request, *args, **kwargs):
10
+ setattr(request, "live_update_interval", interval)
11
+ return view(request, *args, **kwargs)
12
+
13
+ return wrapped
14
+
15
+ return decorator
16
+
17
+
18
+ class LiveUpdateMixin:
19
+ """Mixin to enable automatic refresh for class-based views."""
20
+
21
+ live_update_interval = 5
22
+
23
+ def dispatch(self, request, *args, **kwargs):
24
+ setattr(request, "live_update_interval", self.live_update_interval)
25
+ return super().dispatch(request, *args, **kwargs)