arthexis 0.1.3__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.3.dist-info/METADATA +126 -0
- arthexis-0.1.3.dist-info/RECORD +73 -0
- arthexis-0.1.3.dist-info/WHEEL +5 -0
- arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
- arthexis-0.1.3.dist-info/top_level.txt +5 -0
- config/__init__.py +6 -0
- config/active_app.py +15 -0
- config/asgi.py +29 -0
- config/auth_app.py +8 -0
- config/celery.py +19 -0
- config/context_processors.py +68 -0
- config/loadenv.py +11 -0
- config/logging.py +43 -0
- config/middleware.py +25 -0
- config/offline.py +47 -0
- config/settings.py +374 -0
- config/urls.py +91 -0
- config/wsgi.py +17 -0
- core/__init__.py +0 -0
- core/admin.py +830 -0
- core/apps.py +67 -0
- core/backends.py +82 -0
- core/entity.py +97 -0
- core/environment.py +43 -0
- core/fields.py +70 -0
- core/lcd_screen.py +77 -0
- core/middleware.py +34 -0
- core/models.py +1277 -0
- core/notifications.py +95 -0
- core/release.py +451 -0
- core/system.py +111 -0
- core/tasks.py +100 -0
- core/tests.py +483 -0
- core/urls.py +11 -0
- core/user_data.py +333 -0
- core/views.py +431 -0
- nodes/__init__.py +0 -0
- nodes/actions.py +72 -0
- nodes/admin.py +347 -0
- nodes/apps.py +76 -0
- nodes/lcd.py +151 -0
- nodes/models.py +577 -0
- nodes/tasks.py +50 -0
- nodes/tests.py +1072 -0
- nodes/urls.py +13 -0
- nodes/utils.py +62 -0
- nodes/views.py +262 -0
- ocpp/__init__.py +0 -0
- ocpp/admin.py +392 -0
- ocpp/apps.py +24 -0
- ocpp/consumers.py +267 -0
- ocpp/evcs.py +911 -0
- ocpp/models.py +300 -0
- ocpp/routing.py +9 -0
- ocpp/simulator.py +357 -0
- ocpp/store.py +175 -0
- ocpp/tasks.py +27 -0
- ocpp/test_export_import.py +129 -0
- ocpp/test_rfid.py +345 -0
- ocpp/tests.py +1229 -0
- ocpp/transactions_io.py +119 -0
- ocpp/urls.py +17 -0
- ocpp/views.py +359 -0
- pages/__init__.py +0 -0
- pages/admin.py +231 -0
- pages/apps.py +10 -0
- pages/checks.py +41 -0
- pages/context_processors.py +72 -0
- pages/models.py +224 -0
- pages/tests.py +628 -0
- pages/urls.py +17 -0
- pages/utils.py +13 -0
- pages/views.py +191 -0
nodes/admin.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
from django.contrib import admin, messages
|
|
2
|
+
from django.urls import path, reverse
|
|
3
|
+
from django.shortcuts import redirect, render
|
|
4
|
+
from django.utils.html import format_html
|
|
5
|
+
from django import forms
|
|
6
|
+
from django.contrib.admin.widgets import FilteredSelectMultiple
|
|
7
|
+
from app.widgets import CopyColorWidget, CodeEditorWidget
|
|
8
|
+
from django.db import models
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from django.http import HttpResponse
|
|
12
|
+
import base64
|
|
13
|
+
import pyperclip
|
|
14
|
+
from pyperclip import PyperclipException
|
|
15
|
+
import uuid
|
|
16
|
+
from .utils import capture_screenshot, save_screenshot
|
|
17
|
+
from .actions import NodeAction
|
|
18
|
+
|
|
19
|
+
from .models import (
|
|
20
|
+
Node,
|
|
21
|
+
EmailOutbox,
|
|
22
|
+
NodeRole,
|
|
23
|
+
ContentSample,
|
|
24
|
+
NodeTask,
|
|
25
|
+
NetMessage,
|
|
26
|
+
User,
|
|
27
|
+
)
|
|
28
|
+
from core.admin import UserAdmin as CoreUserAdmin
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NodeAdminForm(forms.ModelForm):
|
|
32
|
+
class Meta:
|
|
33
|
+
model = Node
|
|
34
|
+
fields = "__all__"
|
|
35
|
+
widgets = {"badge_color": CopyColorWidget()}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@admin.register(Node)
|
|
39
|
+
class NodeAdmin(admin.ModelAdmin):
|
|
40
|
+
list_display = (
|
|
41
|
+
"hostname",
|
|
42
|
+
"mac_address",
|
|
43
|
+
"address",
|
|
44
|
+
"port",
|
|
45
|
+
"role",
|
|
46
|
+
"last_seen",
|
|
47
|
+
)
|
|
48
|
+
search_fields = ("hostname", "address", "mac_address")
|
|
49
|
+
change_list_template = "admin/nodes/node/change_list.html"
|
|
50
|
+
change_form_template = "admin/nodes/node/change_form.html"
|
|
51
|
+
form = NodeAdminForm
|
|
52
|
+
actions = ["run_task", "take_screenshots"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_urls(self):
|
|
56
|
+
urls = super().get_urls()
|
|
57
|
+
custom = [
|
|
58
|
+
path(
|
|
59
|
+
"register-current/",
|
|
60
|
+
self.admin_site.admin_view(self.register_current),
|
|
61
|
+
name="nodes_node_register_current",
|
|
62
|
+
),
|
|
63
|
+
path(
|
|
64
|
+
"<int:node_id>/action/<str:action>/",
|
|
65
|
+
self.admin_site.admin_view(self.action_view),
|
|
66
|
+
name="nodes_node_action",
|
|
67
|
+
),
|
|
68
|
+
path(
|
|
69
|
+
"<int:node_id>/public-key/",
|
|
70
|
+
self.admin_site.admin_view(self.public_key),
|
|
71
|
+
name="nodes_node_public_key",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
return custom + urls
|
|
75
|
+
|
|
76
|
+
def register_current(self, request):
|
|
77
|
+
"""Create or update this host and offer browser node registration."""
|
|
78
|
+
node, created = Node.register_current()
|
|
79
|
+
if created:
|
|
80
|
+
self.message_user(
|
|
81
|
+
request, f"Current host registered as {node}", messages.SUCCESS
|
|
82
|
+
)
|
|
83
|
+
token = uuid.uuid4().hex
|
|
84
|
+
context = {
|
|
85
|
+
"token": token,
|
|
86
|
+
"register_url": reverse("register-node"),
|
|
87
|
+
}
|
|
88
|
+
return render(request, "admin/nodes/node/register_remote.html", context)
|
|
89
|
+
|
|
90
|
+
def public_key(self, request, node_id):
|
|
91
|
+
node = self.get_object(request, node_id)
|
|
92
|
+
if not node:
|
|
93
|
+
self.message_user(request, "Unknown node", messages.ERROR)
|
|
94
|
+
return redirect("..")
|
|
95
|
+
security_dir = Path(settings.BASE_DIR) / "security"
|
|
96
|
+
pub_path = security_dir / f"{node.public_endpoint}.pub"
|
|
97
|
+
if pub_path.exists():
|
|
98
|
+
response = HttpResponse(pub_path.read_bytes(), content_type="text/plain")
|
|
99
|
+
response["Content-Disposition"] = f'attachment; filename="{pub_path.name}"'
|
|
100
|
+
return response
|
|
101
|
+
self.message_user(request, "Public key not found", messages.ERROR)
|
|
102
|
+
return redirect("..")
|
|
103
|
+
|
|
104
|
+
def run_task(self, request, queryset):
|
|
105
|
+
if "apply" in request.POST:
|
|
106
|
+
recipe_text = request.POST.get("recipe", "")
|
|
107
|
+
task_obj, _ = NodeTask.objects.get_or_create(recipe=recipe_text)
|
|
108
|
+
results = []
|
|
109
|
+
for node in queryset:
|
|
110
|
+
try:
|
|
111
|
+
output = task_obj.run(node)
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
output = str(exc)
|
|
114
|
+
results.append((node, output))
|
|
115
|
+
context = {"recipe": recipe_text, "results": results}
|
|
116
|
+
return render(request, "admin/nodes/task_result.html", context)
|
|
117
|
+
context = {"nodes": queryset}
|
|
118
|
+
return render(request, "admin/nodes/node/run_task.html", context)
|
|
119
|
+
|
|
120
|
+
run_task.short_description = "Run task"
|
|
121
|
+
|
|
122
|
+
@admin.action(description="Take Screenshots")
|
|
123
|
+
def take_screenshots(self, request, queryset):
|
|
124
|
+
tx = uuid.uuid4()
|
|
125
|
+
sources = getattr(settings, "SCREENSHOT_SOURCES", ["/"])
|
|
126
|
+
count = 0
|
|
127
|
+
for node in queryset:
|
|
128
|
+
for source in sources:
|
|
129
|
+
try:
|
|
130
|
+
url = source.format(node=node, address=node.address, port=node.port)
|
|
131
|
+
except Exception:
|
|
132
|
+
url = source
|
|
133
|
+
if not url.startswith("http"):
|
|
134
|
+
url = f"http://{node.address}:{node.port}{url}"
|
|
135
|
+
try:
|
|
136
|
+
path = capture_screenshot(url)
|
|
137
|
+
except Exception as exc: # pragma: no cover - selenium issues
|
|
138
|
+
self.message_user(request, f"{node}: {exc}", messages.ERROR)
|
|
139
|
+
continue
|
|
140
|
+
sample = save_screenshot(
|
|
141
|
+
path, node=node, method="ADMIN", transaction_uuid=tx
|
|
142
|
+
)
|
|
143
|
+
if sample:
|
|
144
|
+
count += 1
|
|
145
|
+
self.message_user(request, f"{count} screenshots captured", messages.SUCCESS)
|
|
146
|
+
|
|
147
|
+
def changeform_view(self, request, object_id=None, form_url="", extra_context=None):
|
|
148
|
+
extra_context = extra_context or {}
|
|
149
|
+
extra_context["node_actions"] = NodeAction.get_actions()
|
|
150
|
+
if object_id:
|
|
151
|
+
extra_context["public_key_url"] = reverse(
|
|
152
|
+
"admin:nodes_node_public_key", args=[object_id]
|
|
153
|
+
)
|
|
154
|
+
return super().changeform_view(
|
|
155
|
+
request, object_id, form_url, extra_context=extra_context
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def action_view(self, request, node_id, action):
|
|
159
|
+
node = self.get_object(request, node_id)
|
|
160
|
+
action_cls = NodeAction.registry.get(action)
|
|
161
|
+
if not node or not action_cls:
|
|
162
|
+
self.message_user(request, "Unknown node action", messages.ERROR)
|
|
163
|
+
return redirect("..")
|
|
164
|
+
try:
|
|
165
|
+
result = action_cls.run(node)
|
|
166
|
+
if hasattr(result, "status_code"):
|
|
167
|
+
return result
|
|
168
|
+
self.message_user(
|
|
169
|
+
request,
|
|
170
|
+
f"{action_cls.display_name} executed successfully",
|
|
171
|
+
messages.SUCCESS,
|
|
172
|
+
)
|
|
173
|
+
except NotImplementedError:
|
|
174
|
+
self.message_user(
|
|
175
|
+
request,
|
|
176
|
+
"Remote node actions are not yet implemented",
|
|
177
|
+
messages.WARNING,
|
|
178
|
+
)
|
|
179
|
+
except Exception as exc: # pragma: no cover - unexpected errors
|
|
180
|
+
self.message_user(request, str(exc), messages.ERROR)
|
|
181
|
+
return redirect(reverse("admin:nodes_node_change", args=[node_id]))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@admin.register(EmailOutbox)
|
|
185
|
+
class EmailOutboxAdmin(admin.ModelAdmin):
|
|
186
|
+
list_display = ("node", "host", "port", "username", "use_tls", "use_ssl")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class NodeRoleAdminForm(forms.ModelForm):
|
|
190
|
+
nodes = forms.ModelMultipleChoiceField(
|
|
191
|
+
queryset=Node.objects.all(),
|
|
192
|
+
required=False,
|
|
193
|
+
widget=FilteredSelectMultiple("Nodes", False),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
class Meta:
|
|
197
|
+
model = NodeRole
|
|
198
|
+
fields = ("name", "description", "nodes")
|
|
199
|
+
|
|
200
|
+
def __init__(self, *args, **kwargs):
|
|
201
|
+
super().__init__(*args, **kwargs)
|
|
202
|
+
if self.instance.pk:
|
|
203
|
+
self.fields["nodes"].initial = self.instance.node_set.all()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@admin.register(NodeRole)
|
|
207
|
+
class NodeRoleAdmin(admin.ModelAdmin):
|
|
208
|
+
form = NodeRoleAdminForm
|
|
209
|
+
list_display = ("name", "description")
|
|
210
|
+
|
|
211
|
+
def save_model(self, request, obj, form, change):
|
|
212
|
+
obj.node_set.set(form.cleaned_data.get("nodes", []))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@admin.register(ContentSample)
|
|
216
|
+
class ContentSampleAdmin(admin.ModelAdmin):
|
|
217
|
+
list_display = ("name", "kind", "node", "user", "created_at")
|
|
218
|
+
readonly_fields = ("created_at", "name", "user", "image_preview")
|
|
219
|
+
|
|
220
|
+
def get_urls(self):
|
|
221
|
+
urls = super().get_urls()
|
|
222
|
+
custom = [
|
|
223
|
+
path(
|
|
224
|
+
"from-clipboard/",
|
|
225
|
+
self.admin_site.admin_view(self.add_from_clipboard),
|
|
226
|
+
name="nodes_contentsample_from_clipboard",
|
|
227
|
+
),
|
|
228
|
+
path(
|
|
229
|
+
"capture/",
|
|
230
|
+
self.admin_site.admin_view(self.capture_now),
|
|
231
|
+
name="nodes_contentsample_capture",
|
|
232
|
+
),
|
|
233
|
+
]
|
|
234
|
+
return custom + urls
|
|
235
|
+
|
|
236
|
+
def add_from_clipboard(self, request):
|
|
237
|
+
try:
|
|
238
|
+
content = pyperclip.paste()
|
|
239
|
+
except PyperclipException as exc: # pragma: no cover - depends on OS clipboard
|
|
240
|
+
self.message_user(request, f"Clipboard error: {exc}", level=messages.ERROR)
|
|
241
|
+
return redirect("..")
|
|
242
|
+
if not content:
|
|
243
|
+
self.message_user(request, "Clipboard is empty.", level=messages.INFO)
|
|
244
|
+
return redirect("..")
|
|
245
|
+
if ContentSample.objects.filter(content=content, kind=ContentSample.TEXT).exists():
|
|
246
|
+
self.message_user(
|
|
247
|
+
request, "Duplicate sample not created.", level=messages.INFO
|
|
248
|
+
)
|
|
249
|
+
return redirect("..")
|
|
250
|
+
user = request.user if request.user.is_authenticated else None
|
|
251
|
+
ContentSample.objects.create(content=content, user=user, kind=ContentSample.TEXT)
|
|
252
|
+
self.message_user(
|
|
253
|
+
request, "Text sample added from clipboard.", level=messages.SUCCESS
|
|
254
|
+
)
|
|
255
|
+
return redirect("..")
|
|
256
|
+
|
|
257
|
+
def capture_now(self, request):
|
|
258
|
+
node = Node.get_local()
|
|
259
|
+
url = request.build_absolute_uri("/")
|
|
260
|
+
try:
|
|
261
|
+
path = capture_screenshot(url)
|
|
262
|
+
except Exception as exc: # pragma: no cover - depends on selenium setup
|
|
263
|
+
self.message_user(request, str(exc), level=messages.ERROR)
|
|
264
|
+
return redirect("..")
|
|
265
|
+
sample = save_screenshot(path, node=node, method="ADMIN")
|
|
266
|
+
if sample:
|
|
267
|
+
self.message_user(request, f"Screenshot saved to {path}", messages.SUCCESS)
|
|
268
|
+
else:
|
|
269
|
+
self.message_user(
|
|
270
|
+
request, "Duplicate screenshot; not saved", messages.INFO
|
|
271
|
+
)
|
|
272
|
+
return redirect("..")
|
|
273
|
+
|
|
274
|
+
@admin.display(description="Screenshot")
|
|
275
|
+
def image_preview(self, obj):
|
|
276
|
+
if not obj or obj.kind != ContentSample.IMAGE or not obj.path:
|
|
277
|
+
return ""
|
|
278
|
+
file_path = Path(obj.path)
|
|
279
|
+
if not file_path.is_absolute():
|
|
280
|
+
file_path = settings.LOG_DIR / file_path
|
|
281
|
+
if not file_path.exists():
|
|
282
|
+
return "File not found"
|
|
283
|
+
with file_path.open("rb") as f:
|
|
284
|
+
encoded = base64.b64encode(f.read()).decode("ascii")
|
|
285
|
+
return format_html(
|
|
286
|
+
'<img src="data:image/png;base64,{}" style="max-width:100%;" />',
|
|
287
|
+
encoded,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@admin.register(NetMessage)
|
|
292
|
+
class NetMessageAdmin(admin.ModelAdmin):
|
|
293
|
+
list_display = ("subject", "body", "reach", "created", "complete")
|
|
294
|
+
search_fields = ("subject", "body")
|
|
295
|
+
list_filter = ("complete", "reach")
|
|
296
|
+
ordering = ("-created",)
|
|
297
|
+
readonly_fields = ("complete",)
|
|
298
|
+
actions = ["send_messages"]
|
|
299
|
+
|
|
300
|
+
def send_messages(self, request, queryset):
|
|
301
|
+
for msg in queryset:
|
|
302
|
+
msg.propagate()
|
|
303
|
+
self.message_user(request, f"{queryset.count()} messages sent")
|
|
304
|
+
|
|
305
|
+
send_messages.short_description = "Send selected messages"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class NodeTaskForm(forms.ModelForm):
|
|
309
|
+
class Meta:
|
|
310
|
+
model = NodeTask
|
|
311
|
+
fields = "__all__"
|
|
312
|
+
widgets = {"recipe": CodeEditorWidget()}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@admin.register(NodeTask)
|
|
316
|
+
class NodeTaskAdmin(admin.ModelAdmin):
|
|
317
|
+
form = NodeTaskForm
|
|
318
|
+
list_display = ("recipe", "role", "created")
|
|
319
|
+
actions = ["execute"]
|
|
320
|
+
|
|
321
|
+
def execute(self, request, queryset):
|
|
322
|
+
if queryset.count() != 1:
|
|
323
|
+
self.message_user(
|
|
324
|
+
request, "Please select exactly one task", messages.ERROR
|
|
325
|
+
)
|
|
326
|
+
return
|
|
327
|
+
task_obj = queryset.first()
|
|
328
|
+
if "apply" in request.POST:
|
|
329
|
+
node_ids = request.POST.getlist("nodes")
|
|
330
|
+
nodes_qs = Node.objects.filter(pk__in=node_ids)
|
|
331
|
+
results = []
|
|
332
|
+
for node in nodes_qs:
|
|
333
|
+
try:
|
|
334
|
+
output = task_obj.run(node)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
output = str(exc)
|
|
337
|
+
results.append((node, output))
|
|
338
|
+
context = {"recipe": task_obj.recipe, "results": results}
|
|
339
|
+
return render(request, "admin/nodes/task_result.html", context)
|
|
340
|
+
nodes = Node.objects.all()
|
|
341
|
+
context = {"nodes": nodes, "task_obj": task_obj}
|
|
342
|
+
return render(request, "admin/nodes/nodetask/run.html", context)
|
|
343
|
+
|
|
344
|
+
execute.short_description = "Run task on nodes"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
admin.site.register(User, CoreUserAdmin)
|
nodes/apps.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import socket
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from django.apps import AppConfig
|
|
9
|
+
from django.conf import settings
|
|
10
|
+
from django.core.signals import request_started
|
|
11
|
+
from django.db import connections
|
|
12
|
+
from django.db.utils import OperationalError
|
|
13
|
+
from utils import revision
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _startup_notification() -> None:
|
|
20
|
+
"""Queue a notification with host:port and version on a background thread."""
|
|
21
|
+
|
|
22
|
+
host = socket.gethostname()
|
|
23
|
+
try:
|
|
24
|
+
address = socket.gethostbyname(host)
|
|
25
|
+
except socket.gaierror:
|
|
26
|
+
address = host
|
|
27
|
+
|
|
28
|
+
port = os.environ.get("PORT", "8000")
|
|
29
|
+
|
|
30
|
+
version = ""
|
|
31
|
+
ver_path = Path(settings.BASE_DIR) / "VERSION"
|
|
32
|
+
if ver_path.exists():
|
|
33
|
+
version = ver_path.read_text().strip()
|
|
34
|
+
|
|
35
|
+
revision_value = revision.get_revision()
|
|
36
|
+
rev_short = revision_value[-6:] if revision_value else ""
|
|
37
|
+
|
|
38
|
+
body = f"v{version}"
|
|
39
|
+
if rev_short:
|
|
40
|
+
body += f" r{rev_short}"
|
|
41
|
+
|
|
42
|
+
def _worker() -> None: # pragma: no cover - background thread
|
|
43
|
+
# Allow the LCD a moment to become ready and retry a few times
|
|
44
|
+
for _ in range(5):
|
|
45
|
+
try:
|
|
46
|
+
from nodes.models import NetMessage
|
|
47
|
+
|
|
48
|
+
NetMessage.broadcast(subject=f"{address}:{port}", body=body)
|
|
49
|
+
break
|
|
50
|
+
except Exception:
|
|
51
|
+
time.sleep(1)
|
|
52
|
+
|
|
53
|
+
threading.Thread(target=_worker, name="startup-notify", daemon=True).start()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _trigger_startup_notification(**_: object) -> None:
|
|
57
|
+
"""Send the startup notification once a request has started."""
|
|
58
|
+
|
|
59
|
+
request_started.disconnect(_trigger_startup_notification, dispatch_uid="nodes-startup")
|
|
60
|
+
try:
|
|
61
|
+
connections["default"].ensure_connection()
|
|
62
|
+
except OperationalError:
|
|
63
|
+
logger.exception("Startup notification skipped: database unavailable")
|
|
64
|
+
return
|
|
65
|
+
_startup_notification()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NodesConfig(AppConfig):
|
|
69
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
70
|
+
name = "nodes"
|
|
71
|
+
verbose_name = "Node Infrastructure"
|
|
72
|
+
|
|
73
|
+
def ready(self): # pragma: no cover - exercised on app start
|
|
74
|
+
request_started.connect(
|
|
75
|
+
_trigger_startup_notification, dispatch_uid="nodes-startup"
|
|
76
|
+
)
|
nodes/lcd.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Minimal driver for PCF8574/PCF8574A I2C LCD1602 displays.
|
|
2
|
+
|
|
3
|
+
The implementation is adapted from the example provided in the
|
|
4
|
+
instructions. It is intentionally lightweight and only implements the
|
|
5
|
+
operations required for this project: initialisation, clearing the
|
|
6
|
+
screen and writing text to a specific position.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
try: # pragma: no cover - hardware dependent
|
|
17
|
+
import smbus # type: ignore
|
|
18
|
+
except Exception: # pragma: no cover - missing dependency
|
|
19
|
+
try: # pragma: no cover - hardware dependent
|
|
20
|
+
import smbus2 as smbus # type: ignore
|
|
21
|
+
except Exception: # pragma: no cover - missing dependency
|
|
22
|
+
smbus = None # type: ignore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LCDUnavailableError(RuntimeError):
|
|
26
|
+
"""Raised when the LCD cannot be initialised."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class _BusWrapper:
|
|
31
|
+
"""Wrapper around :class:`smbus.SMBus` to allow mocking in tests."""
|
|
32
|
+
|
|
33
|
+
channel: int
|
|
34
|
+
|
|
35
|
+
def write_byte(self, addr: int, data: int) -> None: # pragma: no cover - thin wrapper
|
|
36
|
+
if smbus is None:
|
|
37
|
+
raise LCDUnavailableError("smbus not available")
|
|
38
|
+
bus = smbus.SMBus(self.channel)
|
|
39
|
+
bus.write_byte(addr, data)
|
|
40
|
+
bus.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CharLCD1602:
|
|
44
|
+
"""Minimal driver for PCF8574/PCF8574A I2C backpack (LCD1602)."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, bus: _BusWrapper | None = None) -> None:
|
|
47
|
+
if smbus is None: # pragma: no cover - hardware dependent
|
|
48
|
+
raise LCDUnavailableError("smbus not available")
|
|
49
|
+
self.bus = bus or _BusWrapper(1)
|
|
50
|
+
self.BLEN = 1
|
|
51
|
+
self.PCF8574_address = 0x27
|
|
52
|
+
self.PCF8574A_address = 0x3F
|
|
53
|
+
self.LCD_ADDR = self.PCF8574_address
|
|
54
|
+
|
|
55
|
+
def _write_word(self, addr: int, data: int) -> None:
|
|
56
|
+
if self.BLEN:
|
|
57
|
+
data |= 0x08
|
|
58
|
+
else:
|
|
59
|
+
data &= 0xF7
|
|
60
|
+
self.bus.write_byte(addr, data)
|
|
61
|
+
|
|
62
|
+
def _pulse_enable(self, data: int) -> None:
|
|
63
|
+
self._write_word(self.LCD_ADDR, data | 0x04)
|
|
64
|
+
time.sleep(0.0005)
|
|
65
|
+
self._write_word(self.LCD_ADDR, data & ~0x04)
|
|
66
|
+
time.sleep(0.0001)
|
|
67
|
+
|
|
68
|
+
def send_command(self, cmd: int) -> None:
|
|
69
|
+
high = cmd & 0xF0
|
|
70
|
+
low = (cmd << 4) & 0xF0
|
|
71
|
+
self._write_word(self.LCD_ADDR, high)
|
|
72
|
+
self._pulse_enable(high)
|
|
73
|
+
self._write_word(self.LCD_ADDR, low)
|
|
74
|
+
self._pulse_enable(low)
|
|
75
|
+
# Give the LCD time to process the command to avoid garbled output.
|
|
76
|
+
time.sleep(0.001)
|
|
77
|
+
|
|
78
|
+
def send_data(self, data: int) -> None:
|
|
79
|
+
high = (data & 0xF0) | 0x01
|
|
80
|
+
low = ((data << 4) & 0xF0) | 0x01
|
|
81
|
+
self._write_word(self.LCD_ADDR, high)
|
|
82
|
+
self._pulse_enable(high)
|
|
83
|
+
self._write_word(self.LCD_ADDR, low)
|
|
84
|
+
self._pulse_enable(low)
|
|
85
|
+
# Allow the LCD controller to catch up between data writes.
|
|
86
|
+
time.sleep(0.001)
|
|
87
|
+
|
|
88
|
+
def i2c_scan(self) -> List[str]: # pragma: no cover - requires hardware
|
|
89
|
+
"""Return a list of detected I2C addresses.
|
|
90
|
+
|
|
91
|
+
The implementation relies on the external ``i2cdetect`` command. On
|
|
92
|
+
systems where ``i2c-tools`` is not installed or the command cannot be
|
|
93
|
+
executed (e.g. insufficient permissions), the function returns an empty
|
|
94
|
+
list so callers can fall back to a sensible default address.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
cmd = "i2cdetect -y 1 | awk 'NR>1 {$1=\"\"; print}'"
|
|
98
|
+
try:
|
|
99
|
+
out = subprocess.check_output(cmd, shell=True).decode()
|
|
100
|
+
except Exception: # pragma: no cover - depends on environment
|
|
101
|
+
return []
|
|
102
|
+
out = out.replace("\n", "").replace(" --", "")
|
|
103
|
+
return [tok for tok in out.split(" ") if tok]
|
|
104
|
+
|
|
105
|
+
def init_lcd(self, addr: int | None = None, bl: int = 1) -> None:
|
|
106
|
+
self.BLEN = 1 if bl else 0
|
|
107
|
+
if addr is None:
|
|
108
|
+
try:
|
|
109
|
+
found = self.i2c_scan()
|
|
110
|
+
except Exception: # pragma: no cover - i2c detection issues
|
|
111
|
+
found = []
|
|
112
|
+
if "3f" in found or "3F" in found:
|
|
113
|
+
self.LCD_ADDR = self.PCF8574A_address
|
|
114
|
+
else:
|
|
115
|
+
# Default to the common PCF8574 address (0x27) when detection
|
|
116
|
+
# fails or returns no recognised addresses. This mirrors the
|
|
117
|
+
# behaviour prior to introducing automatic address detection and
|
|
118
|
+
# prevents the display from remaining uninitialised on systems
|
|
119
|
+
# without ``i2c-tools``.
|
|
120
|
+
self.LCD_ADDR = self.PCF8574_address
|
|
121
|
+
else:
|
|
122
|
+
self.LCD_ADDR = addr
|
|
123
|
+
|
|
124
|
+
time.sleep(0.05)
|
|
125
|
+
self.send_command(0x33)
|
|
126
|
+
self.send_command(0x32)
|
|
127
|
+
self.send_command(0x28)
|
|
128
|
+
self.send_command(0x0C)
|
|
129
|
+
self.send_command(0x06)
|
|
130
|
+
self.clear()
|
|
131
|
+
self._write_word(self.LCD_ADDR, 0x00)
|
|
132
|
+
|
|
133
|
+
def clear(self) -> None:
|
|
134
|
+
self.send_command(0x01)
|
|
135
|
+
time.sleep(0.002)
|
|
136
|
+
|
|
137
|
+
def reset(self) -> None:
|
|
138
|
+
"""Re-run the initialisation sequence to recover the display."""
|
|
139
|
+
self.init_lcd(addr=self.LCD_ADDR, bl=self.BLEN)
|
|
140
|
+
|
|
141
|
+
def set_backlight(self, on: bool = True) -> None: # pragma: no cover - hardware dependent
|
|
142
|
+
self.BLEN = 1 if on else 0
|
|
143
|
+
self._write_word(self.LCD_ADDR, 0x00)
|
|
144
|
+
|
|
145
|
+
def write(self, x: int, y: int, s: str) -> None:
|
|
146
|
+
x = max(0, min(15, int(x)))
|
|
147
|
+
y = 0 if int(y) <= 0 else 1
|
|
148
|
+
addr = 0x80 + 0x40 * y + x
|
|
149
|
+
self.send_command(addr)
|
|
150
|
+
for ch in str(s):
|
|
151
|
+
self.send_data(ord(ch))
|