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.

Files changed (73) hide show
  1. arthexis-0.1.3.dist-info/METADATA +126 -0
  2. arthexis-0.1.3.dist-info/RECORD +73 -0
  3. arthexis-0.1.3.dist-info/WHEEL +5 -0
  4. arthexis-0.1.3.dist-info/licenses/LICENSE +21 -0
  5. arthexis-0.1.3.dist-info/top_level.txt +5 -0
  6. config/__init__.py +6 -0
  7. config/active_app.py +15 -0
  8. config/asgi.py +29 -0
  9. config/auth_app.py +8 -0
  10. config/celery.py +19 -0
  11. config/context_processors.py +68 -0
  12. config/loadenv.py +11 -0
  13. config/logging.py +43 -0
  14. config/middleware.py +25 -0
  15. config/offline.py +47 -0
  16. config/settings.py +374 -0
  17. config/urls.py +91 -0
  18. config/wsgi.py +17 -0
  19. core/__init__.py +0 -0
  20. core/admin.py +830 -0
  21. core/apps.py +67 -0
  22. core/backends.py +82 -0
  23. core/entity.py +97 -0
  24. core/environment.py +43 -0
  25. core/fields.py +70 -0
  26. core/lcd_screen.py +77 -0
  27. core/middleware.py +34 -0
  28. core/models.py +1277 -0
  29. core/notifications.py +95 -0
  30. core/release.py +451 -0
  31. core/system.py +111 -0
  32. core/tasks.py +100 -0
  33. core/tests.py +483 -0
  34. core/urls.py +11 -0
  35. core/user_data.py +333 -0
  36. core/views.py +431 -0
  37. nodes/__init__.py +0 -0
  38. nodes/actions.py +72 -0
  39. nodes/admin.py +347 -0
  40. nodes/apps.py +76 -0
  41. nodes/lcd.py +151 -0
  42. nodes/models.py +577 -0
  43. nodes/tasks.py +50 -0
  44. nodes/tests.py +1072 -0
  45. nodes/urls.py +13 -0
  46. nodes/utils.py +62 -0
  47. nodes/views.py +262 -0
  48. ocpp/__init__.py +0 -0
  49. ocpp/admin.py +392 -0
  50. ocpp/apps.py +24 -0
  51. ocpp/consumers.py +267 -0
  52. ocpp/evcs.py +911 -0
  53. ocpp/models.py +300 -0
  54. ocpp/routing.py +9 -0
  55. ocpp/simulator.py +357 -0
  56. ocpp/store.py +175 -0
  57. ocpp/tasks.py +27 -0
  58. ocpp/test_export_import.py +129 -0
  59. ocpp/test_rfid.py +345 -0
  60. ocpp/tests.py +1229 -0
  61. ocpp/transactions_io.py +119 -0
  62. ocpp/urls.py +17 -0
  63. ocpp/views.py +359 -0
  64. pages/__init__.py +0 -0
  65. pages/admin.py +231 -0
  66. pages/apps.py +10 -0
  67. pages/checks.py +41 -0
  68. pages/context_processors.py +72 -0
  69. pages/models.py +224 -0
  70. pages/tests.py +628 -0
  71. pages/urls.py +17 -0
  72. pages/utils.py +13 -0
  73. 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))