arthexis 0.1.21__py3-none-any.whl → 0.1.22__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.21.dist-info → arthexis-0.1.22.dist-info}/METADATA +8 -8
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/RECORD +31 -31
- config/settings.py +4 -0
- config/urls.py +5 -0
- core/admin.py +139 -19
- core/environment.py +2 -239
- core/models.py +419 -2
- core/system.py +76 -0
- core/tests.py +152 -8
- core/views.py +35 -1
- nodes/admin.py +148 -38
- nodes/apps.py +11 -0
- nodes/models.py +26 -6
- nodes/tests.py +214 -1
- nodes/views.py +1 -0
- ocpp/admin.py +20 -1
- ocpp/consumers.py +1 -0
- ocpp/models.py +23 -1
- ocpp/tasks.py +99 -1
- ocpp/tests.py +227 -2
- ocpp/views.py +281 -3
- pages/admin.py +112 -15
- pages/apps.py +32 -0
- pages/forms.py +31 -8
- pages/models.py +42 -2
- pages/tests.py +361 -22
- pages/urls.py +5 -0
- pages/views.py +264 -11
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/WHEEL +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/licenses/LICENSE +0 -0
- {arthexis-0.1.21.dist-info → arthexis-0.1.22.dist-info}/top_level.txt +0 -0
nodes/tests.py
CHANGED
|
@@ -48,6 +48,7 @@ from .classifiers import run_default_classifiers
|
|
|
48
48
|
from .utils import capture_rpi_snapshot, capture_screenshot, save_screenshot
|
|
49
49
|
from django.db.utils import DatabaseError
|
|
50
50
|
|
|
51
|
+
from .admin import NodeAdmin
|
|
51
52
|
from .models import (
|
|
52
53
|
Node,
|
|
53
54
|
EmailOutbox,
|
|
@@ -67,7 +68,8 @@ from .backends import OutboxEmailBackend
|
|
|
67
68
|
from .tasks import capture_node_screenshot, poll_unreachable_upstream, sample_clipboard
|
|
68
69
|
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
69
70
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
70
|
-
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount
|
|
71
|
+
from core.models import Package, PackageRelease, SecurityGroup, RFID, EnergyAccount, Todo
|
|
72
|
+
from requests.exceptions import SSLError
|
|
71
73
|
|
|
72
74
|
|
|
73
75
|
class NodeBadgeColorTests(TestCase):
|
|
@@ -742,6 +744,59 @@ class NodeGetLocalTests(TestCase):
|
|
|
742
744
|
self.assertEqual(node.current_relation, Node.Relation.UPSTREAM)
|
|
743
745
|
|
|
744
746
|
|
|
747
|
+
class NodeEnsureKeysTests(TestCase):
|
|
748
|
+
def setUp(self):
|
|
749
|
+
self.tempdir = TemporaryDirectory()
|
|
750
|
+
self.base = Path(self.tempdir.name)
|
|
751
|
+
self.override = override_settings(BASE_DIR=self.base)
|
|
752
|
+
self.override.enable()
|
|
753
|
+
self.node = Node.objects.create(
|
|
754
|
+
hostname="ensure-host",
|
|
755
|
+
address="127.0.0.1",
|
|
756
|
+
port=8000,
|
|
757
|
+
mac_address="00:11:22:33:44:55",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def tearDown(self):
|
|
761
|
+
self.override.disable()
|
|
762
|
+
self.tempdir.cleanup()
|
|
763
|
+
|
|
764
|
+
def test_regenerates_missing_keys(self):
|
|
765
|
+
self.node.ensure_keys()
|
|
766
|
+
security_dir = self.base / "security"
|
|
767
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
768
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
769
|
+
original_public = self.node.public_key
|
|
770
|
+
priv_path.unlink()
|
|
771
|
+
pub_path.unlink()
|
|
772
|
+
|
|
773
|
+
self.node.ensure_keys()
|
|
774
|
+
|
|
775
|
+
self.assertTrue(priv_path.exists())
|
|
776
|
+
self.assertTrue(pub_path.exists())
|
|
777
|
+
self.assertNotEqual(self.node.public_key, original_public)
|
|
778
|
+
|
|
779
|
+
def test_regenerates_outdated_keys(self):
|
|
780
|
+
self.node.ensure_keys()
|
|
781
|
+
security_dir = self.base / "security"
|
|
782
|
+
priv_path = security_dir / self.node.public_endpoint
|
|
783
|
+
pub_path = security_dir / f"{self.node.public_endpoint}.pub"
|
|
784
|
+
original_private = priv_path.read_bytes()
|
|
785
|
+
original_public = pub_path.read_bytes()
|
|
786
|
+
|
|
787
|
+
old_time = (timezone.now() - timedelta(seconds=5)).timestamp()
|
|
788
|
+
os.utime(priv_path, (old_time, old_time))
|
|
789
|
+
os.utime(pub_path, (old_time, old_time))
|
|
790
|
+
|
|
791
|
+
with override_settings(NODE_KEY_MAX_AGE=timedelta(seconds=1)):
|
|
792
|
+
self.node.ensure_keys()
|
|
793
|
+
|
|
794
|
+
self.node.refresh_from_db()
|
|
795
|
+
self.assertNotEqual(priv_path.read_bytes(), original_private)
|
|
796
|
+
self.assertNotEqual(pub_path.read_bytes(), original_public)
|
|
797
|
+
self.assertNotEqual(self.node.public_key, original_public.decode())
|
|
798
|
+
|
|
799
|
+
|
|
745
800
|
class NodeInfoViewTests(TestCase):
|
|
746
801
|
def setUp(self):
|
|
747
802
|
self.mac = "02:00:00:00:00:01"
|
|
@@ -790,6 +845,16 @@ class NodeInfoViewTests(TestCase):
|
|
|
790
845
|
payload = response.json()
|
|
791
846
|
self.assertEqual(payload["port"], 8443)
|
|
792
847
|
|
|
848
|
+
def test_includes_role_in_payload(self):
|
|
849
|
+
role, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
850
|
+
self.node.role = role
|
|
851
|
+
self.node.save(update_fields=["role"])
|
|
852
|
+
|
|
853
|
+
response = self.client.get(self.url)
|
|
854
|
+
self.assertEqual(response.status_code, 200)
|
|
855
|
+
payload = response.json()
|
|
856
|
+
self.assertEqual(payload.get("role"), "Terminal")
|
|
857
|
+
|
|
793
858
|
|
|
794
859
|
class RegisterVisitorNodeMessageTests(TestCase):
|
|
795
860
|
def setUp(self):
|
|
@@ -1651,6 +1716,60 @@ class NodeAdminTests(TestCase):
|
|
|
1651
1716
|
response, reverse("admin:nodes_node_register_current")
|
|
1652
1717
|
)
|
|
1653
1718
|
|
|
1719
|
+
def test_apply_remote_node_info_updates_role(self):
|
|
1720
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1721
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1722
|
+
node = Node.objects.create(
|
|
1723
|
+
hostname="remote-node",
|
|
1724
|
+
address="10.0.0.20",
|
|
1725
|
+
port=8001,
|
|
1726
|
+
mac_address="00:11:22:33:44:aa",
|
|
1727
|
+
role=terminal,
|
|
1728
|
+
)
|
|
1729
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1730
|
+
|
|
1731
|
+
payload = {
|
|
1732
|
+
"hostname": node.hostname,
|
|
1733
|
+
"address": node.address,
|
|
1734
|
+
"port": node.port,
|
|
1735
|
+
"role": "Control",
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1739
|
+
node.refresh_from_db()
|
|
1740
|
+
|
|
1741
|
+
self.assertIn("role", changed)
|
|
1742
|
+
self.assertEqual(node.role, control)
|
|
1743
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1744
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1745
|
+
|
|
1746
|
+
def test_apply_remote_node_info_accepts_role_name_key(self):
|
|
1747
|
+
terminal, _ = NodeRole.objects.get_or_create(name="Terminal")
|
|
1748
|
+
control, _ = NodeRole.objects.get_or_create(name="Control")
|
|
1749
|
+
node = Node.objects.create(
|
|
1750
|
+
hostname="role-name-node",
|
|
1751
|
+
address="10.0.0.21",
|
|
1752
|
+
port=8002,
|
|
1753
|
+
mac_address="00:11:22:33:44:bb",
|
|
1754
|
+
role=terminal,
|
|
1755
|
+
)
|
|
1756
|
+
admin_instance = NodeAdmin(Node, admin.site)
|
|
1757
|
+
|
|
1758
|
+
payload = {
|
|
1759
|
+
"hostname": node.hostname,
|
|
1760
|
+
"address": node.address,
|
|
1761
|
+
"port": node.port,
|
|
1762
|
+
"role_name": "Control",
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
changed = admin_instance._apply_remote_node_info(node, payload)
|
|
1766
|
+
node.refresh_from_db()
|
|
1767
|
+
|
|
1768
|
+
self.assertIn("role", changed)
|
|
1769
|
+
self.assertEqual(node.role, control)
|
|
1770
|
+
self.assertTrue(control.node_set.filter(pk=node.pk).exists())
|
|
1771
|
+
self.assertFalse(terminal.node_set.filter(pk=node.pk).exists())
|
|
1772
|
+
|
|
1654
1773
|
@pytest.mark.feature("screenshot-poll")
|
|
1655
1774
|
@patch("nodes.admin.capture_screenshot")
|
|
1656
1775
|
def test_capture_site_screenshot_from_admin(self, mock_capture_screenshot):
|
|
@@ -1726,6 +1845,44 @@ class NodeAdminTests(TestCase):
|
|
|
1726
1845
|
payload = json.loads(mock_post.call_args[1]["data"])
|
|
1727
1846
|
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1728
1847
|
|
|
1848
|
+
@patch("nodes.admin.requests.post")
|
|
1849
|
+
def test_proxy_view_falls_back_to_http_after_ssl_error(self, mock_post):
|
|
1850
|
+
self.client.get(reverse("admin:nodes_node_register_current"))
|
|
1851
|
+
remote = Node.objects.create(
|
|
1852
|
+
hostname="remote-https",
|
|
1853
|
+
address="198.51.100.20",
|
|
1854
|
+
port=443,
|
|
1855
|
+
mac_address="aa:bb:cc:dd:ee:10",
|
|
1856
|
+
)
|
|
1857
|
+
local_node = Node.get_local()
|
|
1858
|
+
success_response = SimpleNamespace(
|
|
1859
|
+
ok=True,
|
|
1860
|
+
json=lambda: {
|
|
1861
|
+
"login_url": "http://remote.example/nodes/proxy/login/token",
|
|
1862
|
+
"expires": "2025-01-01T00:00:00",
|
|
1863
|
+
},
|
|
1864
|
+
status_code=200,
|
|
1865
|
+
text="ok",
|
|
1866
|
+
)
|
|
1867
|
+
mock_post.side_effect = [
|
|
1868
|
+
SSLError("wrong version number"),
|
|
1869
|
+
success_response,
|
|
1870
|
+
]
|
|
1871
|
+
|
|
1872
|
+
response = self.client.get(
|
|
1873
|
+
reverse("admin:nodes_node_proxy", args=[remote.pk])
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
self.assertEqual(response.status_code, 200)
|
|
1877
|
+
self.assertEqual(mock_post.call_count, 2)
|
|
1878
|
+
first_url = mock_post.call_args_list[0].args[0]
|
|
1879
|
+
second_url = mock_post.call_args_list[1].args[0]
|
|
1880
|
+
self.assertTrue(first_url.startswith("https://"))
|
|
1881
|
+
self.assertTrue(second_url.startswith("http://"))
|
|
1882
|
+
self.assertIn("/nodes/proxy/session/", second_url)
|
|
1883
|
+
payload = json.loads(mock_post.call_args_list[-1].kwargs["data"])
|
|
1884
|
+
self.assertEqual(payload.get("requester"), str(local_node.uuid))
|
|
1885
|
+
|
|
1729
1886
|
def test_proxy_link_displayed_for_remote_nodes(self):
|
|
1730
1887
|
Node.objects.create(
|
|
1731
1888
|
hostname="remote",
|
|
@@ -1737,6 +1894,51 @@ class NodeAdminTests(TestCase):
|
|
|
1737
1894
|
proxy_url = reverse("admin:nodes_node_proxy", args=[1])
|
|
1738
1895
|
self.assertContains(response, proxy_url)
|
|
1739
1896
|
|
|
1897
|
+
def test_visit_link_uses_local_admin_dashboard_for_local_node(self):
|
|
1898
|
+
node_admin = admin.site._registry[Node]
|
|
1899
|
+
local_node = self._create_local_node()
|
|
1900
|
+
|
|
1901
|
+
link_html = node_admin.visit_link(local_node)
|
|
1902
|
+
|
|
1903
|
+
self.assertIn(reverse("admin:index"), link_html)
|
|
1904
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
1905
|
+
|
|
1906
|
+
def test_visit_link_prefers_remote_hostname_for_dashboard(self):
|
|
1907
|
+
node_admin = admin.site._registry[Node]
|
|
1908
|
+
remote = Node.objects.create(
|
|
1909
|
+
hostname="remote.example.com",
|
|
1910
|
+
address="198.51.100.20",
|
|
1911
|
+
port=8443,
|
|
1912
|
+
mac_address="aa:bb:cc:dd:ee:ff",
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
link_html = node_admin.visit_link(remote)
|
|
1916
|
+
|
|
1917
|
+
self.assertIn("https://remote.example.com:8443/admin/", link_html)
|
|
1918
|
+
self.assertIn("target=\"_blank\"", link_html)
|
|
1919
|
+
|
|
1920
|
+
def test_iter_remote_urls_handles_hostname_with_path_and_port(self):
|
|
1921
|
+
node_admin = admin.site._registry[Node]
|
|
1922
|
+
remote = SimpleNamespace(
|
|
1923
|
+
public_endpoint="",
|
|
1924
|
+
address="",
|
|
1925
|
+
hostname="example.com/interface",
|
|
1926
|
+
port=8443,
|
|
1927
|
+
)
|
|
1928
|
+
|
|
1929
|
+
urls = list(node_admin._iter_remote_urls(remote, "/nodes/proxy/session/"))
|
|
1930
|
+
|
|
1931
|
+
self.assertIn(
|
|
1932
|
+
"https://example.com:8443/interface/nodes/proxy/session/",
|
|
1933
|
+
urls,
|
|
1934
|
+
)
|
|
1935
|
+
self.assertIn(
|
|
1936
|
+
"http://example.com:8443/interface/nodes/proxy/session/",
|
|
1937
|
+
urls,
|
|
1938
|
+
)
|
|
1939
|
+
combined = "".join(urls)
|
|
1940
|
+
self.assertNotIn("interface:8443", combined)
|
|
1941
|
+
|
|
1740
1942
|
|
|
1741
1943
|
@pytest.mark.feature("screenshot-poll")
|
|
1742
1944
|
@override_settings(SCREENSHOT_SOURCES=["/one", "/two"])
|
|
@@ -3412,6 +3614,17 @@ class StartupHandlerTests(TestCase):
|
|
|
3412
3614
|
|
|
3413
3615
|
mock_start.assert_called_once()
|
|
3414
3616
|
|
|
3617
|
+
def test_handler_skips_during_migrate_command(self):
|
|
3618
|
+
import sys
|
|
3619
|
+
|
|
3620
|
+
from nodes.apps import _trigger_startup_notification
|
|
3621
|
+
|
|
3622
|
+
with patch("nodes.apps._startup_notification") as mock_start:
|
|
3623
|
+
with patch.object(sys, "argv", ["manage.py", "migrate"]):
|
|
3624
|
+
_trigger_startup_notification()
|
|
3625
|
+
|
|
3626
|
+
mock_start.assert_not_called()
|
|
3627
|
+
|
|
3415
3628
|
|
|
3416
3629
|
class NotificationManagerTests(TestCase):
|
|
3417
3630
|
def test_send_writes_trimmed_lines(self):
|
nodes/views.py
CHANGED
ocpp/admin.py
CHANGED
|
@@ -112,12 +112,19 @@ class LogViewAdminMixin:
|
|
|
112
112
|
|
|
113
113
|
@admin.register(ChargerConfiguration)
|
|
114
114
|
class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
115
|
-
list_display = (
|
|
115
|
+
list_display = (
|
|
116
|
+
"charger_identifier",
|
|
117
|
+
"connector_display",
|
|
118
|
+
"origin_display",
|
|
119
|
+
"created_at",
|
|
120
|
+
)
|
|
116
121
|
list_filter = ("connector_id",)
|
|
117
122
|
search_fields = ("charger_identifier",)
|
|
118
123
|
readonly_fields = (
|
|
119
124
|
"charger_identifier",
|
|
120
125
|
"connector_id",
|
|
126
|
+
"origin_display",
|
|
127
|
+
"evcs_snapshot_at",
|
|
121
128
|
"created_at",
|
|
122
129
|
"updated_at",
|
|
123
130
|
"linked_chargers",
|
|
@@ -132,6 +139,8 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
132
139
|
"fields": (
|
|
133
140
|
"charger_identifier",
|
|
134
141
|
"connector_id",
|
|
142
|
+
"origin_display",
|
|
143
|
+
"evcs_snapshot_at",
|
|
135
144
|
"linked_chargers",
|
|
136
145
|
"created_at",
|
|
137
146
|
"updated_at",
|
|
@@ -185,6 +194,16 @@ class ChargerConfigurationAdmin(admin.ModelAdmin):
|
|
|
185
194
|
def raw_payload_display(self, obj):
|
|
186
195
|
return self._render_json(obj.raw_payload)
|
|
187
196
|
|
|
197
|
+
@admin.display(description="Origin")
|
|
198
|
+
def origin_display(self, obj):
|
|
199
|
+
if obj.evcs_snapshot_at:
|
|
200
|
+
return "EVCS"
|
|
201
|
+
return "Local"
|
|
202
|
+
|
|
203
|
+
def save_model(self, request, obj, form, change):
|
|
204
|
+
obj.evcs_snapshot_at = None
|
|
205
|
+
super().save_model(request, obj, form, change)
|
|
206
|
+
|
|
188
207
|
|
|
189
208
|
@admin.register(Location)
|
|
190
209
|
class LocationAdmin(EntityModelAdmin):
|
ocpp/consumers.py
CHANGED
|
@@ -791,6 +791,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
|
|
|
791
791
|
connector_id=connector_value,
|
|
792
792
|
configuration_keys=normalized_entries,
|
|
793
793
|
unknown_keys=unknown_values,
|
|
794
|
+
evcs_snapshot_at=timezone.now(),
|
|
794
795
|
raw_payload=raw_payload,
|
|
795
796
|
)
|
|
796
797
|
Charger.objects.filter(charger_id=self.charger_id).update(
|
ocpp/models.py
CHANGED
|
@@ -48,6 +48,7 @@ class Charger(Entity):
|
|
|
48
48
|
"""Known charge point."""
|
|
49
49
|
|
|
50
50
|
_PLACEHOLDER_SERIAL_RE = re.compile(r"^<[^>]+>$")
|
|
51
|
+
_AUTO_LOCATION_SANITIZE_RE = re.compile(r"[^0-9A-Za-z_-]+")
|
|
51
52
|
|
|
52
53
|
OPERATIVE_STATUSES = {
|
|
53
54
|
"Available",
|
|
@@ -324,6 +325,16 @@ class Charger(Entity):
|
|
|
324
325
|
)
|
|
325
326
|
return normalized
|
|
326
327
|
|
|
328
|
+
@classmethod
|
|
329
|
+
def sanitize_auto_location_name(cls, value: str) -> str:
|
|
330
|
+
"""Return a location name containing only safe characters."""
|
|
331
|
+
|
|
332
|
+
sanitized = cls._AUTO_LOCATION_SANITIZE_RE.sub("_", value)
|
|
333
|
+
sanitized = re.sub(r"_+", "_", sanitized).strip("_")
|
|
334
|
+
if not sanitized:
|
|
335
|
+
return "Charger"
|
|
336
|
+
return sanitized
|
|
337
|
+
|
|
327
338
|
AGGREGATE_CONNECTOR_SLUG = "all"
|
|
328
339
|
|
|
329
340
|
def identity_tuple(self) -> tuple[str, int | None]:
|
|
@@ -459,7 +470,8 @@ class Charger(Entity):
|
|
|
459
470
|
if existing:
|
|
460
471
|
self.location = existing.location
|
|
461
472
|
else:
|
|
462
|
-
|
|
473
|
+
auto_name = type(self).sanitize_auto_location_name(self.charger_id)
|
|
474
|
+
location, _ = Location.objects.get_or_create(name=auto_name)
|
|
463
475
|
self.location = location
|
|
464
476
|
if update_list is not None and "location" not in update_list:
|
|
465
477
|
update_list.append("location")
|
|
@@ -663,6 +675,14 @@ class ChargerConfiguration(models.Model):
|
|
|
663
675
|
blank=True,
|
|
664
676
|
help_text=_("Keys returned in the unknownKey list."),
|
|
665
677
|
)
|
|
678
|
+
evcs_snapshot_at = models.DateTimeField(
|
|
679
|
+
_("EVCS snapshot at"),
|
|
680
|
+
null=True,
|
|
681
|
+
blank=True,
|
|
682
|
+
help_text=_(
|
|
683
|
+
"Timestamp when this configuration was received from the charge point."
|
|
684
|
+
),
|
|
685
|
+
)
|
|
666
686
|
raw_payload = models.JSONField(
|
|
667
687
|
default=dict,
|
|
668
688
|
blank=True,
|
|
@@ -1023,6 +1043,8 @@ class DataTransferMessage(models.Model):
|
|
|
1023
1043
|
|
|
1024
1044
|
class Meta:
|
|
1025
1045
|
ordering = ["-created_at"]
|
|
1046
|
+
verbose_name = _("Data Message")
|
|
1047
|
+
verbose_name_plural = _("Data Messages")
|
|
1026
1048
|
indexes = [
|
|
1027
1049
|
models.Index(
|
|
1028
1050
|
fields=["ocpp_message_id"],
|
ocpp/tasks.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
3
|
+
import uuid
|
|
2
4
|
from datetime import date, datetime, time, timedelta
|
|
3
5
|
from pathlib import Path
|
|
4
6
|
|
|
7
|
+
from asgiref.sync import async_to_sync
|
|
5
8
|
from celery import shared_task
|
|
6
9
|
from django.conf import settings
|
|
7
10
|
from django.contrib.auth import get_user_model
|
|
@@ -11,11 +14,106 @@ from django.db.models import Q
|
|
|
11
14
|
from core import mailer
|
|
12
15
|
from nodes.models import Node
|
|
13
16
|
|
|
14
|
-
from .
|
|
17
|
+
from . import store
|
|
18
|
+
from .models import Charger, MeterValue, Transaction
|
|
15
19
|
|
|
16
20
|
logger = logging.getLogger(__name__)
|
|
17
21
|
|
|
18
22
|
|
|
23
|
+
@shared_task
|
|
24
|
+
def check_charge_point_configuration(charger_pk: int) -> bool:
|
|
25
|
+
"""Request the latest configuration from a connected charge point."""
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
charger = Charger.objects.get(pk=charger_pk)
|
|
29
|
+
except Charger.DoesNotExist:
|
|
30
|
+
logger.warning(
|
|
31
|
+
"Unable to request configuration for missing charger %s",
|
|
32
|
+
charger_pk,
|
|
33
|
+
)
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
connector_value = charger.connector_id
|
|
37
|
+
if connector_value is not None:
|
|
38
|
+
logger.debug(
|
|
39
|
+
"Skipping charger %s: connector %s is not eligible for automatic configuration checks",
|
|
40
|
+
charger.charger_id,
|
|
41
|
+
connector_value,
|
|
42
|
+
)
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
ws = store.get_connection(charger.charger_id, connector_value)
|
|
46
|
+
if ws is None:
|
|
47
|
+
logger.info(
|
|
48
|
+
"Charge point %s is not connected; configuration request skipped",
|
|
49
|
+
charger.charger_id,
|
|
50
|
+
)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
message_id = uuid.uuid4().hex
|
|
54
|
+
payload: dict[str, object] = {}
|
|
55
|
+
msg = json.dumps([2, message_id, "GetConfiguration", payload])
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async_to_sync(ws.send)(msg)
|
|
59
|
+
except Exception as exc: # pragma: no cover - network error
|
|
60
|
+
logger.warning(
|
|
61
|
+
"Failed to send GetConfiguration to %s (%s)",
|
|
62
|
+
charger.charger_id,
|
|
63
|
+
exc,
|
|
64
|
+
)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
log_key = store.identity_key(charger.charger_id, connector_value)
|
|
68
|
+
store.add_log(log_key, f"< {msg}", log_type="charger")
|
|
69
|
+
store.register_pending_call(
|
|
70
|
+
message_id,
|
|
71
|
+
{
|
|
72
|
+
"action": "GetConfiguration",
|
|
73
|
+
"charger_id": charger.charger_id,
|
|
74
|
+
"connector_id": connector_value,
|
|
75
|
+
"log_key": log_key,
|
|
76
|
+
"requested_at": timezone.now(),
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
store.schedule_call_timeout(
|
|
80
|
+
message_id,
|
|
81
|
+
timeout=5.0,
|
|
82
|
+
action="GetConfiguration",
|
|
83
|
+
log_key=log_key,
|
|
84
|
+
message=(
|
|
85
|
+
"GetConfiguration timed out: charger did not respond"
|
|
86
|
+
" (operation may not be supported)"
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
logger.info(
|
|
90
|
+
"Requested configuration from charge point %s",
|
|
91
|
+
charger.charger_id,
|
|
92
|
+
)
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@shared_task
|
|
97
|
+
def schedule_daily_charge_point_configuration_checks() -> int:
|
|
98
|
+
"""Dispatch configuration requests for eligible charge points."""
|
|
99
|
+
|
|
100
|
+
charger_ids = list(
|
|
101
|
+
Charger.objects.filter(connector_id__isnull=True).values_list("pk", flat=True)
|
|
102
|
+
)
|
|
103
|
+
if not charger_ids:
|
|
104
|
+
logger.debug("No eligible charge points available for configuration check")
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
scheduled = 0
|
|
108
|
+
for charger_pk in charger_ids:
|
|
109
|
+
check_charge_point_configuration.delay(charger_pk)
|
|
110
|
+
scheduled += 1
|
|
111
|
+
logger.info(
|
|
112
|
+
"Scheduled configuration checks for %s charge point(s)", scheduled
|
|
113
|
+
)
|
|
114
|
+
return scheduled
|
|
115
|
+
|
|
116
|
+
|
|
19
117
|
@shared_task
|
|
20
118
|
def purge_meter_values() -> int:
|
|
21
119
|
"""Delete meter values older than 7 days.
|