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.
- arthexis-0.1.26.dist-info/METADATA +272 -0
- arthexis-0.1.26.dist-info/RECORD +111 -0
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/licenses/LICENSE +674 -674
- config/__init__.py +5 -5
- config/active_app.py +15 -15
- config/asgi.py +29 -29
- config/auth_app.py +7 -7
- config/celery.py +32 -25
- config/context_processors.py +67 -68
- config/horologia_app.py +7 -7
- config/loadenv.py +11 -11
- config/logging.py +59 -48
- config/middleware.py +71 -25
- config/offline.py +49 -49
- config/settings.py +676 -492
- config/settings_helpers.py +109 -0
- config/urls.py +228 -159
- config/wsgi.py +17 -17
- core/admin.py +4052 -2066
- core/admin_history.py +50 -50
- core/admindocs.py +192 -151
- core/apps.py +350 -223
- core/auto_upgrade.py +72 -0
- core/backends.py +311 -124
- core/changelog.py +403 -0
- core/entity.py +149 -133
- core/environment.py +60 -43
- core/fields.py +168 -75
- core/form_fields.py +75 -0
- core/github_helper.py +188 -25
- core/github_issues.py +183 -172
- core/github_repos.py +72 -0
- core/lcd_screen.py +78 -78
- core/liveupdate.py +25 -25
- core/log_paths.py +114 -100
- core/mailer.py +89 -83
- core/middleware.py +91 -91
- core/models.py +5041 -2195
- core/notifications.py +105 -105
- core/public_wifi.py +267 -227
- core/reference_utils.py +107 -0
- core/release.py +940 -346
- core/rfid_import_export.py +113 -0
- core/sigil_builder.py +149 -131
- core/sigil_context.py +20 -20
- core/sigil_resolver.py +250 -284
- core/system.py +1425 -230
- core/tasks.py +538 -199
- core/temp_passwords.py +181 -0
- core/test_system_info.py +202 -43
- core/tests.py +2673 -1069
- core/tests_liveupdate.py +17 -17
- core/urls.py +11 -11
- core/user_data.py +681 -495
- core/views.py +2484 -789
- core/widgets.py +213 -51
- nodes/admin.py +2236 -445
- nodes/apps.py +98 -70
- nodes/backends.py +160 -53
- nodes/dns.py +203 -0
- nodes/feature_checks.py +133 -0
- nodes/lcd.py +165 -165
- nodes/models.py +2375 -870
- nodes/reports.py +411 -0
- nodes/rfid_sync.py +210 -0
- nodes/signals.py +18 -0
- nodes/tasks.py +141 -46
- nodes/tests.py +5045 -1489
- nodes/urls.py +29 -13
- nodes/utils.py +172 -73
- nodes/views.py +1768 -304
- ocpp/admin.py +1775 -481
- ocpp/apps.py +25 -25
- ocpp/consumers.py +1843 -630
- ocpp/evcs.py +844 -928
- ocpp/evcs_discovery.py +158 -0
- ocpp/models.py +1417 -640
- ocpp/network.py +398 -0
- ocpp/reference_utils.py +42 -0
- ocpp/routing.py +11 -9
- ocpp/simulator.py +745 -368
- ocpp/status_display.py +26 -0
- ocpp/store.py +603 -403
- ocpp/tasks.py +479 -31
- ocpp/test_export_import.py +131 -130
- ocpp/test_rfid.py +1072 -540
- ocpp/tests.py +5494 -2296
- ocpp/transactions_io.py +197 -165
- ocpp/urls.py +50 -50
- ocpp/views.py +2024 -912
- pages/admin.py +1123 -396
- pages/apps.py +45 -10
- pages/checks.py +40 -40
- pages/context_processors.py +151 -85
- pages/defaults.py +13 -0
- pages/forms.py +221 -0
- pages/middleware.py +213 -153
- pages/models.py +720 -252
- pages/module_defaults.py +156 -0
- pages/site_config.py +137 -0
- pages/tasks.py +74 -0
- pages/tests.py +4009 -1389
- pages/urls.py +38 -20
- pages/utils.py +93 -12
- pages/views.py +1736 -762
- arthexis-0.1.9.dist-info/METADATA +0 -168
- arthexis-0.1.9.dist-info/RECORD +0 -92
- core/workgroup_urls.py +0 -17
- core/workgroup_views.py +0 -94
- nodes/actions.py +0 -70
- {arthexis-0.1.9.dist-info → arthexis-0.1.26.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
payload
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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)
|