arthexis 0.1.9__py3-none-any.whl → 0.1.10__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.

ocpp/consumers.py CHANGED
@@ -1,8 +1,11 @@
1
+ import asyncio
1
2
  import json
2
3
  import base64
4
+ import ipaddress
5
+ import re
3
6
  from datetime import datetime
4
7
  from django.utils import timezone
5
- from core.models import EnergyAccount, RFID as CoreRFID
8
+ from core.models import EnergyAccount, Reference, RFID as CoreRFID
6
9
  from nodes.models import NetMessage
7
10
 
8
11
  from channels.generic.websocket import AsyncWebsocketConsumer
@@ -15,14 +18,97 @@ from decimal import Decimal
15
18
  from django.utils.dateparse import parse_datetime
16
19
  from .models import Transaction, Charger, MeterValue
17
20
 
21
+ FORWARDED_PAIR_RE = re.compile(r"for=(?:\"?)(?P<value>[^;,\"\s]+)(?:\"?)", re.IGNORECASE)
22
+
23
+
24
+ def _parse_ip(value: str | None):
25
+ """Return an :mod:`ipaddress` object for the provided value, if valid."""
26
+
27
+ candidate = (value or "").strip()
28
+ if not candidate or candidate.lower() == "unknown":
29
+ return None
30
+ if candidate.lower().startswith("for="):
31
+ candidate = candidate[4:].strip()
32
+ candidate = candidate.strip("'\"")
33
+ if candidate.startswith("["):
34
+ closing = candidate.find("]")
35
+ if closing != -1:
36
+ candidate = candidate[1:closing]
37
+ else:
38
+ candidate = candidate[1:]
39
+ # Remove any comma separated values that may remain.
40
+ if "," in candidate:
41
+ candidate = candidate.split(",", 1)[0].strip()
42
+ try:
43
+ parsed = ipaddress.ip_address(candidate)
44
+ except ValueError:
45
+ host, sep, maybe_port = candidate.rpartition(":")
46
+ if not sep or not maybe_port.isdigit():
47
+ return None
48
+ try:
49
+ parsed = ipaddress.ip_address(host)
50
+ except ValueError:
51
+ return None
52
+ return parsed
53
+
54
+
55
+ def _resolve_client_ip(scope: dict) -> str | None:
56
+ """Return the most useful client IP for the provided ASGI scope."""
57
+
58
+ headers = scope.get("headers") or []
59
+ header_map: dict[str, list[str]] = {}
60
+ for key_bytes, value_bytes in headers:
61
+ try:
62
+ key = key_bytes.decode("latin1").lower()
63
+ except Exception:
64
+ continue
65
+ try:
66
+ value = value_bytes.decode("latin1")
67
+ except Exception:
68
+ value = ""
69
+ header_map.setdefault(key, []).append(value)
70
+
71
+ candidates: list[str] = []
72
+ for raw in header_map.get("x-forwarded-for", []):
73
+ candidates.extend(part.strip() for part in raw.split(","))
74
+ for raw in header_map.get("forwarded", []):
75
+ for segment in raw.split(","):
76
+ match = FORWARDED_PAIR_RE.search(segment)
77
+ if match:
78
+ candidates.append(match.group("value"))
79
+ candidates.extend(header_map.get("x-real-ip", []))
80
+ client = scope.get("client")
81
+ if client:
82
+ candidates.append((client[0] or "").strip())
83
+
84
+ fallback: str | None = None
85
+ for raw in candidates:
86
+ parsed = _parse_ip(raw)
87
+ if not parsed:
88
+ continue
89
+ ip_text = str(parsed)
90
+ if parsed.is_loopback:
91
+ if fallback is None:
92
+ fallback = ip_text
93
+ continue
94
+ return ip_text
95
+ return fallback
96
+
18
97
 
19
98
  class SinkConsumer(AsyncWebsocketConsumer):
20
99
  """Accept any message without validation."""
21
100
 
22
101
  @requires_network
23
102
  async def connect(self) -> None:
103
+ self.client_ip = _resolve_client_ip(self.scope)
104
+ if not store.register_ip_connection(self.client_ip, self):
105
+ await self.close(code=4003)
106
+ return
24
107
  await self.accept()
25
108
 
109
+ async def disconnect(self, close_code):
110
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
111
+
26
112
  async def receive(
27
113
  self, text_data: str | None = None, bytes_data: bytes | None = None
28
114
  ) -> None:
@@ -39,22 +125,37 @@ class SinkConsumer(AsyncWebsocketConsumer):
39
125
  class CSMSConsumer(AsyncWebsocketConsumer):
40
126
  """Very small subset of OCPP 1.6 CSMS behaviour."""
41
127
 
128
+ consumption_update_interval = 300
129
+
42
130
  @requires_network
43
131
  async def connect(self):
44
132
  self.charger_id = self.scope["url_route"]["kwargs"].get("cid", "")
45
133
  self.connector_value: int | None = None
46
134
  self.store_key = store.pending_key(self.charger_id)
47
135
  self.aggregate_charger: Charger | None = None
136
+ self._consumption_task: asyncio.Task | None = None
137
+ self._consumption_message_uuid: str | None = None
48
138
  subprotocol = None
49
139
  offered = self.scope.get("subprotocols", [])
50
140
  if "ocpp1.6" in offered:
51
141
  subprotocol = "ocpp1.6"
142
+ self.client_ip = _resolve_client_ip(self.scope)
143
+ self._header_reference_created = False
52
144
  # Close any pending connection for this charger so reconnections do
53
145
  # not leak stale consumers when the connector id has not been
54
146
  # negotiated yet.
55
147
  existing = store.connections.get(self.store_key)
56
148
  if existing is not None:
149
+ store.release_ip_connection(getattr(existing, "client_ip", None), existing)
57
150
  await existing.close()
151
+ if not store.register_ip_connection(self.client_ip, self):
152
+ store.add_log(
153
+ self.store_key,
154
+ f"Rejected connection from {self.client_ip or 'unknown'}: rate limit exceeded",
155
+ log_type="charger",
156
+ )
157
+ await self.close(code=4003)
158
+ return
58
159
  await self.accept(subprotocol=subprotocol)
59
160
  store.add_log(
60
161
  self.store_key,
@@ -70,6 +171,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
70
171
  connector_id=None,
71
172
  defaults={"last_path": self.scope.get("path", "")},
72
173
  )
174
+ await database_sync_to_async(self.charger.refresh_manager_node)()
73
175
  self.aggregate_charger = self.charger
74
176
  location_name = await sync_to_async(
75
177
  lambda: self.charger.location.name if self.charger.location else ""
@@ -95,11 +197,19 @@ class CSMSConsumer(AsyncWebsocketConsumer):
95
197
 
96
198
  async def _assign_connector(self, connector: int | str | None) -> None:
97
199
  """Ensure ``self.charger`` matches the provided connector id."""
98
- if connector is None:
99
- return
100
- try:
101
- connector_value = int(connector)
102
- except (TypeError, ValueError):
200
+ if connector in (None, "", "-"):
201
+ connector_value = None
202
+ else:
203
+ try:
204
+ connector_value = int(connector)
205
+ if connector_value == 0:
206
+ connector_value = None
207
+ except (TypeError, ValueError):
208
+ return
209
+ if connector_value is None:
210
+ if not self._header_reference_created and self.client_ip:
211
+ await database_sync_to_async(self._ensure_console_reference)()
212
+ self._header_reference_created = True
103
213
  return
104
214
  if (
105
215
  self.connector_value == connector_value
@@ -110,15 +220,15 @@ class CSMSConsumer(AsyncWebsocketConsumer):
110
220
  not self.aggregate_charger
111
221
  or self.aggregate_charger.connector_id is not None
112
222
  ):
113
- self.aggregate_charger = await database_sync_to_async(
223
+ aggregate, _ = await database_sync_to_async(
114
224
  Charger.objects.get_or_create
115
225
  )(
116
226
  charger_id=self.charger_id,
117
227
  connector_id=None,
118
228
  defaults={"last_path": self.scope.get("path", "")},
119
- )[
120
- 0
121
- ]
229
+ )
230
+ await database_sync_to_async(aggregate.refresh_manager_node)()
231
+ self.aggregate_charger = aggregate
122
232
  existing = await database_sync_to_async(
123
233
  Charger.objects.filter(
124
234
  charger_id=self.charger_id, connector_id=connector_value
@@ -126,6 +236,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
126
236
  )()
127
237
  if existing:
128
238
  self.charger = existing
239
+ await database_sync_to_async(self.charger.refresh_manager_node)()
129
240
  else:
130
241
 
131
242
  def _create_connector():
@@ -139,6 +250,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
139
250
  ):
140
251
  charger.last_path = self.scope.get("path")
141
252
  charger.save(update_fields=["last_path"])
253
+ charger.refresh_manager_node()
142
254
  return charger
143
255
 
144
256
  self.charger = await database_sync_to_async(_create_connector)()
@@ -168,6 +280,39 @@ class CSMSConsumer(AsyncWebsocketConsumer):
168
280
  self.store_key = new_key
169
281
  self.connector_value = connector_value
170
282
 
283
+ def _ensure_console_reference(self) -> None:
284
+ """Create or update a header reference for the connected charger."""
285
+
286
+ ip = (self.client_ip or "").strip()
287
+ serial = (self.charger_id or "").strip()
288
+ if not ip or not serial:
289
+ return
290
+ host = ip
291
+ if ":" in host and not host.startswith("["):
292
+ host = f"[{host}]"
293
+ url = f"http://{host}:8900"
294
+ alt_text = f"{serial} Console"
295
+ reference, _ = Reference.objects.get_or_create(
296
+ alt_text=alt_text,
297
+ defaults={
298
+ "value": url,
299
+ "show_in_header": True,
300
+ "method": "link",
301
+ },
302
+ )
303
+ updated_fields: list[str] = []
304
+ if reference.value != url:
305
+ reference.value = url
306
+ updated_fields.append("value")
307
+ if reference.method != "link":
308
+ reference.method = "link"
309
+ updated_fields.append("method")
310
+ if not reference.show_in_header:
311
+ reference.show_in_header = True
312
+ updated_fields.append("show_in_header")
313
+ if updated_fields:
314
+ reference.save(update_fields=updated_fields)
315
+
171
316
  async def _store_meter_values(self, payload: dict, raw_message: str) -> None:
172
317
  """Parse a MeterValues payload into MeterValue rows."""
173
318
  connector_raw = payload.get("connectorId")
@@ -309,46 +454,110 @@ class CSMSConsumer(AsyncWebsocketConsumer):
309
454
  target.firmware_status_info = status_info
310
455
  target.firmware_timestamp = timestamp
311
456
 
312
- async def _broadcast_charging_started(self) -> None:
313
- """Send a network message announcing a charging session."""
457
+ async def _cancel_consumption_message(self) -> None:
458
+ """Stop any scheduled consumption message updates."""
314
459
 
315
- def _message_payload() -> dict[str, str] | None:
316
- charger = self.charger
317
- aggregate = self.aggregate_charger
318
- if not charger:
319
- return None
320
- location_name = ""
321
- if charger.location_id:
322
- location_name = charger.location.name
323
- elif aggregate and aggregate.location_id:
324
- location_name = aggregate.location.name
325
- cid_value = (
326
- charger.connector_slug
327
- if charger.connector_id is not None
328
- else Charger.AGGREGATE_CONNECTOR_SLUG
460
+ task = self._consumption_task
461
+ self._consumption_task = None
462
+ if task:
463
+ task.cancel()
464
+ try:
465
+ await task
466
+ except asyncio.CancelledError:
467
+ pass
468
+ self._consumption_message_uuid = None
469
+
470
+ async def _update_consumption_message(self, tx_id: int) -> str | None:
471
+ """Create or update the Net Message for an active transaction."""
472
+
473
+ existing_uuid = self._consumption_message_uuid
474
+
475
+ def _persist() -> str | None:
476
+ tx = (
477
+ Transaction.objects.select_related("charger")
478
+ .filter(pk=tx_id)
479
+ .first()
329
480
  )
330
- return {
331
- "location": location_name,
332
- "sn": charger.charger_id,
333
- "cid": str(cid_value),
334
- }
335
-
336
- payload = await database_sync_to_async(_message_payload)()
337
- if not payload:
338
- return
481
+ if not tx:
482
+ return None
483
+ charger = tx.charger or self.charger
484
+ serial = ""
485
+ if charger and charger.charger_id:
486
+ serial = charger.charger_id
487
+ elif self.charger_id:
488
+ serial = self.charger_id
489
+ serial = serial[:64]
490
+ if not serial:
491
+ return None
492
+ now_local = timezone.localtime(timezone.now())
493
+ body_value = f"{tx.kw:.1f} kWh {now_local.strftime('%H:%M')}"[:256]
494
+ if existing_uuid:
495
+ msg = NetMessage.objects.filter(uuid=existing_uuid).first()
496
+ if msg:
497
+ msg.subject = serial
498
+ msg.body = body_value
499
+ msg.save(update_fields=["subject", "body"])
500
+ msg.propagate()
501
+ return str(msg.uuid)
502
+ msg = NetMessage.broadcast(subject=serial, body=body_value)
503
+ return str(msg.uuid)
504
+
339
505
  try:
340
- await database_sync_to_async(NetMessage.broadcast)(
341
- subject="charging-started",
342
- body=json.dumps(payload, separators=(",", ":")),
506
+ result = await database_sync_to_async(_persist)()
507
+ except Exception as exc: # pragma: no cover - unexpected errors
508
+ store.add_log(
509
+ self.store_key,
510
+ f"Failed to broadcast consumption message: {exc}",
511
+ log_type="charger",
343
512
  )
344
- except Exception as exc: # pragma: no cover - logging of unexpected errors
513
+ return None
514
+ if result is None:
345
515
  store.add_log(
346
516
  self.store_key,
347
- f"Failed to broadcast charging start: {exc}",
517
+ "Unable to broadcast consumption message: missing data",
348
518
  log_type="charger",
349
519
  )
520
+ return None
521
+ self._consumption_message_uuid = result
522
+ return result
523
+
524
+ async def _consumption_message_loop(self, tx_id: int) -> None:
525
+ """Periodically refresh the consumption Net Message."""
526
+
527
+ try:
528
+ while True:
529
+ await asyncio.sleep(self.consumption_update_interval)
530
+ updated = await self._update_consumption_message(tx_id)
531
+ if not updated:
532
+ break
533
+ except asyncio.CancelledError:
534
+ pass
535
+ except Exception as exc: # pragma: no cover - unexpected errors
536
+ store.add_log(
537
+ self.store_key,
538
+ f"Failed to refresh consumption message: {exc}",
539
+ log_type="charger",
540
+ )
541
+
542
+ async def _start_consumption_updates(self, tx_obj: Transaction) -> None:
543
+ """Send the initial consumption message and schedule updates."""
544
+
545
+ await self._cancel_consumption_message()
546
+ initial = await self._update_consumption_message(tx_obj.pk)
547
+ if not initial:
548
+ return
549
+ task = asyncio.create_task(self._consumption_message_loop(tx_obj.pk))
550
+ task.add_done_callback(lambda _: setattr(self, "_consumption_task", None))
551
+ self._consumption_task = task
350
552
 
351
553
  async def disconnect(self, close_code):
554
+ store.release_ip_connection(getattr(self, "client_ip", None), self)
555
+ tx_obj = None
556
+ if self.charger_id:
557
+ tx_obj = store.get_transaction(self.charger_id, self.connector_value)
558
+ if tx_obj:
559
+ await self._update_consumption_message(tx_obj.pk)
560
+ await self._cancel_consumption_message()
352
561
  store.connections.pop(self.store_key, None)
353
562
  pending_key = store.pending_key(self.charger_id)
354
563
  if self.store_key != pending_key:
@@ -552,7 +761,7 @@ class CSMSConsumer(AsyncWebsocketConsumer):
552
761
  store.start_session_log(self.store_key, tx_obj.pk)
553
762
  store.start_session_lock()
554
763
  store.add_session_message(self.store_key, text_data)
555
- await self._broadcast_charging_started()
764
+ await self._start_consumption_updates(tx_obj)
556
765
  reply_payload = {
557
766
  "transactionId": tx_obj.pk,
558
767
  "idTagInfo": {"status": "Accepted"},
@@ -579,6 +788,8 @@ class CSMSConsumer(AsyncWebsocketConsumer):
579
788
  tx_obj.meter_stop = payload.get("meterStop")
580
789
  tx_obj.stop_time = timezone.now()
581
790
  await database_sync_to_async(tx_obj.save)()
791
+ await self._update_consumption_message(tx_obj.pk)
792
+ await self._cancel_consumption_message()
582
793
  reply_payload = {"idTagInfo": {"status": "Accepted"}}
583
794
  store.end_session_log(self.store_key)
584
795
  store.stop_session_lock()
ocpp/evcs.py CHANGED
@@ -172,7 +172,7 @@ for key, val in _load_state_file().items(): # pragma: no cover - simple load
172
172
  async def simulate_cp(
173
173
  cp_idx: int,
174
174
  host: str,
175
- ws_port: int,
175
+ ws_port: Optional[int],
176
176
  rfid: str,
177
177
  vin: str,
178
178
  cp_path: str,
@@ -196,7 +196,10 @@ async def simulate_cp(
196
196
  if the server closes the connection.
197
197
  """
198
198
 
199
- uri = f"ws://{host}:{ws_port}/{cp_path}"
199
+ if ws_port:
200
+ uri = f"ws://{host}:{ws_port}/{cp_path}"
201
+ else:
202
+ uri = f"ws://{host}/{cp_path}"
200
203
  headers = {}
201
204
  if username and password:
202
205
  userpass = f"{username}:{password}"
@@ -582,7 +585,7 @@ async def simulate_cp(
582
585
  def simulate(
583
586
  *,
584
587
  host: str = "127.0.0.1",
585
- ws_port: int = 8000,
588
+ ws_port: Optional[int] = 8000,
586
589
  rfid: str = "FFFFFFFF",
587
590
  cp_path: str = "CPX",
588
591
  vin: str = "",
ocpp/models.py CHANGED
@@ -8,6 +8,7 @@ from django.urls import reverse
8
8
  from django.utils.translation import gettext_lazy as _
9
9
 
10
10
  from core.entity import Entity, EntityManager
11
+ from nodes.models import Node
11
12
 
12
13
  from core.models import (
13
14
  EnergyAccount,
@@ -58,6 +59,11 @@ class Charger(Entity):
58
59
  null=True,
59
60
  help_text="Optional connector identifier for multi-connector chargers.",
60
61
  )
62
+ public_display = models.BooleanField(
63
+ _("Show on Public Dashboard"),
64
+ default=True,
65
+ help_text="Display this charger on the public status dashboard.",
66
+ )
61
67
  require_rfid = models.BooleanField(
62
68
  _("Require RFID Authorization"),
63
69
  default=False,
@@ -121,6 +127,13 @@ class Charger(Entity):
121
127
  related_name="chargers",
122
128
  )
123
129
  last_path = models.CharField(max_length=255, blank=True)
130
+ manager_node = models.ForeignKey(
131
+ "nodes.Node",
132
+ on_delete=models.SET_NULL,
133
+ null=True,
134
+ blank=True,
135
+ related_name="managed_chargers",
136
+ )
124
137
 
125
138
  def __str__(self) -> str: # pragma: no cover - simple representation
126
139
  return self.charger_id
@@ -240,6 +253,13 @@ class Charger(Entity):
240
253
 
241
254
  def save(self, *args, **kwargs):
242
255
  update_fields = kwargs.get("update_fields")
256
+ update_list = list(update_fields) if update_fields is not None else None
257
+ if not self.manager_node_id:
258
+ local_node = Node.get_local()
259
+ if local_node:
260
+ self.manager_node = local_node
261
+ if update_list is not None and "manager_node" not in update_list:
262
+ update_list.append("manager_node")
243
263
  if not self.location_id:
244
264
  existing = (
245
265
  type(self)
@@ -253,11 +273,10 @@ class Charger(Entity):
253
273
  else:
254
274
  location, _ = Location.objects.get_or_create(name=self.charger_id)
255
275
  self.location = location
256
- if update_fields is not None:
257
- update_list = list(update_fields)
258
- if "location" not in update_list:
259
- update_list.append("location")
260
- kwargs["update_fields"] = update_list
276
+ if update_list is not None and "location" not in update_list:
277
+ update_list.append("location")
278
+ if update_list is not None:
279
+ kwargs["update_fields"] = update_list
261
280
  super().save(*args, **kwargs)
262
281
  ref_value = self._full_url()
263
282
  if not self.reference or self.reference.value != ref_value:
@@ -266,6 +285,20 @@ class Charger(Entity):
266
285
  )
267
286
  super().save(update_fields=["reference"])
268
287
 
288
+ def refresh_manager_node(self, node: Node | None = None) -> Node | None:
289
+ """Ensure ``manager_node`` matches the provided or local node."""
290
+
291
+ node = node or Node.get_local()
292
+ if not node:
293
+ return None
294
+ if self.pk is None:
295
+ self.manager_node = node
296
+ return node
297
+ if self.manager_node_id != node.pk:
298
+ type(self).objects.filter(pk=self.pk).update(manager_node=node)
299
+ self.manager_node = node
300
+ return node
301
+
269
302
  @property
270
303
  def name(self) -> str:
271
304
  if self.location:
@@ -556,7 +589,9 @@ class Simulator(Entity):
556
589
  _("Serial Number"), max_length=100, help_text=_("Charge Point WS path")
557
590
  )
558
591
  host = models.CharField(max_length=100, default="127.0.0.1")
559
- ws_port = models.IntegerField(_("WS Port"), default=8000)
592
+ ws_port = models.IntegerField(
593
+ _("WS Port"), default=8000, null=True, blank=True
594
+ )
560
595
  rfid = models.CharField(
561
596
  max_length=255,
562
597
  default="FFFFFFFF",
@@ -572,6 +607,11 @@ class Simulator(Entity):
572
607
  repeat = models.BooleanField(default=False)
573
608
  username = models.CharField(max_length=100, blank=True)
574
609
  password = models.CharField(max_length=100, blank=True)
610
+ door_open = models.BooleanField(
611
+ _("Door Open"),
612
+ default=False,
613
+ help_text=_("Send a DoorOpen error StatusNotification when enabled."),
614
+ )
575
615
 
576
616
  def __str__(self) -> str: # pragma: no cover - simple representation
577
617
  return self.name
@@ -605,7 +645,9 @@ class Simulator(Entity):
605
645
  path = self.cp_path
606
646
  if not path.endswith("/"):
607
647
  path += "/"
608
- return f"ws://{self.host}:{self.ws_port}/{path}"
648
+ if self.ws_port:
649
+ return f"ws://{self.host}:{self.ws_port}/{path}"
650
+ return f"ws://{self.host}/{path}"
609
651
 
610
652
 
611
653
  class RFID(CoreRFID):
ocpp/simulator.py CHANGED
@@ -3,6 +3,7 @@ import base64
3
3
  import json
4
4
  import random
5
5
  import time
6
+ import uuid
6
7
  from dataclasses import dataclass
7
8
  from typing import Optional
8
9
  import threading
@@ -18,7 +19,7 @@ class SimulatorConfig:
18
19
  """Configuration for a simulated charge point."""
19
20
 
20
21
  host: str = "127.0.0.1"
21
- ws_port: int = 8000
22
+ ws_port: Optional[int] = 8000
22
23
  rfid: str = "FFFFFFFF"
23
24
  vin: str = ""
24
25
  # WebSocket path for the charge point. Defaults to just the charger ID at the root.
@@ -42,14 +43,65 @@ class ChargePointSimulator:
42
43
  self.config = config
43
44
  self._thread: Optional[threading.Thread] = None
44
45
  self._stop_event = threading.Event()
46
+ self._door_open_event = threading.Event()
45
47
  self.status = "stopped"
46
48
  self._connected = threading.Event()
47
49
  self._connect_error = ""
48
50
 
51
+ def trigger_door_open(self) -> None:
52
+ """Queue a DoorOpen status notification for the simulator."""
53
+
54
+ self._door_open_event.set()
55
+
56
+ async def _maybe_send_door_event(self, send, recv) -> None:
57
+ if not self._door_open_event.is_set():
58
+ return
59
+ self._door_open_event.clear()
60
+ cfg = self.config
61
+ store.add_log(
62
+ cfg.cp_path,
63
+ "Sending DoorOpen StatusNotification",
64
+ log_type="simulator",
65
+ )
66
+ event_id = uuid.uuid4().hex
67
+ await send(
68
+ json.dumps(
69
+ [
70
+ 2,
71
+ f"door-open-{event_id}",
72
+ "StatusNotification",
73
+ {
74
+ "connectorId": cfg.connector_id,
75
+ "errorCode": "DoorOpen",
76
+ "status": "Faulted",
77
+ },
78
+ ]
79
+ )
80
+ )
81
+ await recv()
82
+ await send(
83
+ json.dumps(
84
+ [
85
+ 2,
86
+ f"door-closed-{event_id}",
87
+ "StatusNotification",
88
+ {
89
+ "connectorId": cfg.connector_id,
90
+ "errorCode": "NoError",
91
+ "status": "Available",
92
+ },
93
+ ]
94
+ )
95
+ )
96
+ await recv()
97
+
49
98
  @requires_network
50
99
  async def _run_session(self) -> None:
51
100
  cfg = self.config
52
- uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
101
+ if cfg.ws_port:
102
+ uri = f"ws://{cfg.host}:{cfg.ws_port}/{cfg.cp_path}"
103
+ else:
104
+ uri = f"ws://{cfg.host}/{cfg.cp_path}"
53
105
  headers = {}
54
106
  if cfg.username and cfg.password:
55
107
  userpass = f"{cfg.username}:{cfg.password}"
@@ -134,6 +186,7 @@ class ChargePointSimulator:
134
186
 
135
187
  await send(json.dumps([2, "auth", "Authorize", {"idTag": cfg.rfid}]))
136
188
  await recv()
189
+ await self._maybe_send_door_event(send, recv)
137
190
  if not self._connected.is_set():
138
191
  self.status = "running"
139
192
  self._connect_error = "accepted"
@@ -182,10 +235,11 @@ class ChargePointSimulator:
182
235
  ],
183
236
  },
184
237
  ]
185
- )
186
238
  )
187
- await recv()
188
- await asyncio.sleep(cfg.interval)
239
+ )
240
+ await recv()
241
+ await self._maybe_send_door_event(send, recv)
242
+ await asyncio.sleep(cfg.interval)
189
243
 
190
244
  meter_start = random.randint(1000, 2000)
191
245
  await send(
@@ -250,6 +304,7 @@ class ChargePointSimulator:
250
304
  )
251
305
  )
252
306
  await recv()
307
+ await self._maybe_send_door_event(send, recv)
253
308
  await asyncio.sleep(cfg.interval)
254
309
 
255
310
  await send(
@@ -267,6 +322,7 @@ class ChargePointSimulator:
267
322
  )
268
323
  )
269
324
  await recv()
325
+ await self._maybe_send_door_event(send, recv)
270
326
  except asyncio.TimeoutError:
271
327
  if not self._connected.is_set():
272
328
  self._connect_error = "Timeout waiting for response"
@@ -336,6 +392,7 @@ class ChargePointSimulator:
336
392
  self.status = "starting"
337
393
  self._connected.clear()
338
394
  self._connect_error = ""
395
+ self._door_open_event.clear()
339
396
 
340
397
  def _runner() -> None:
341
398
  asyncio.run(self._run())