]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor: dry improvements in web UI and OCPP mock server
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 3 Apr 2026 17:02:57 +0000 (19:02 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 3 Apr 2026 17:02:57 +0000 (19:02 +0200)
Web UI:
- extract localStorage key constants (UI_SERVER_CONFIGURATION_INDEX_KEY,
  TOGGLE_BUTTON_KEY_PREFIX, SHARED_TOGGLE_BUTTON_KEY_PREFIX)
- add deleteLocalStorageByKeyPattern() utility with safe collect-then-
  delete iteration
- add getWebSocketStateName() utility replacing duplicated switch
- remove redundant .trim() calls (already handled by v-model.trim)

OCPP mock server:
- extract FALLBACK_TRANSACTION_ID constant and shared helper method
- replace 20-case match/case with _COMMAND_HANDLERS dict dispatch
- unify _parse_set/get_variable_specs into shared _parse_variable_specs
- add ClassVar annotation for proper mypy typing

tests/ocpp-server/server.py
tests/ocpp-server/test_server.py
ui/web/src/components/actions/StartTransaction.vue
ui/web/src/components/buttons/ToggleButton.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/Constants.ts
ui/web/src/composables/Utils.ts
ui/web/src/composables/index.ts
ui/web/src/views/ChargingStationsView.vue

index 0e510ef9ef95477a57ddd00d1f873f8ac802db59..0ebab66172df90088d6bdaef01e7843d838d4299 100644 (file)
@@ -11,6 +11,7 @@ from datetime import datetime, timedelta, timezone
 from enum import StrEnum
 from functools import partial
 from random import randint
+from typing import ClassVar
 
 import ocpp.v201
 import websockets
@@ -59,6 +60,7 @@ DEFAULT_HOST = "127.0.0.1"
 DEFAULT_PORT = 9000
 DEFAULT_HEARTBEAT_INTERVAL = 60
 DEFAULT_TOTAL_COST = 10.0
+FALLBACK_TRANSACTION_ID = "test_transaction_123"
 MAX_REQUEST_ID = 2**31 - 1
 SHUTDOWN_TIMEOUT = 30.0
 SUBPROTOCOLS: list[websockets.Subprotocol] = [
@@ -447,11 +449,16 @@ class ChargePoint(ocpp.v201.ChargePoint):
         await self.call(request, suppress=False)
         logger.info("%s response received", Action.request_start_transaction)
 
-    async def _send_request_stop_transaction(self):
+    def _get_active_or_fallback_transaction_id(self) -> str:
+        """Return the first active transaction ID, or fall back to a test ID."""
         transaction_id = next(iter(self._active_transactions), "")
         if not transaction_id:
             logger.warning("No active transaction found, using fallback ID")
-            transaction_id = "test_transaction_123"
+            transaction_id = FALLBACK_TRANSACTION_ID
+        return transaction_id
+
+    async def _send_request_stop_transaction(self):
+        transaction_id = self._get_active_or_fallback_transaction_id()
         request = ocpp.v201.call.RequestStopTransaction(transaction_id=transaction_id)
         await self.call(request, suppress=False)
         logger.info("%s response received", Action.request_stop_transaction)
@@ -554,10 +561,7 @@ class ChargePoint(ocpp.v201.ChargePoint):
         await self._call_and_log(request, Action.get_log, LogStatusEnumType.accepted)
 
     async def _send_get_transaction_status(self):
-        transaction_id = next(iter(self._active_transactions), "")
-        if not transaction_id:
-            logger.warning("No active transaction found, using fallback ID")
-            transaction_id = "test_transaction_123"
+        transaction_id = self._get_active_or_fallback_transaction_id()
         request = ocpp.v201.call.GetTransactionStatus(
             transaction_id=transaction_id,
         )
@@ -615,52 +619,37 @@ class ChargePoint(ocpp.v201.ChargePoint):
 
     # --- Command dispatch ---
 
+    _COMMAND_HANDLERS: ClassVar[dict[Action, str]] = {
+        Action.clear_cache: "_send_clear_cache",
+        Action.get_base_report: "_send_get_base_report",
+        Action.get_variables: "_send_get_variables",
+        Action.set_variables: "_send_set_variables",
+        Action.request_start_transaction: "_send_request_start_transaction",
+        Action.request_stop_transaction: "_send_request_stop_transaction",
+        Action.reset: "_send_reset",
+        Action.unlock_connector: "_send_unlock_connector",
+        Action.change_availability: "_send_change_availability",
+        Action.trigger_message: "_send_trigger_message",
+        Action.data_transfer: "_send_data_transfer",
+        Action.certificate_signed: "_send_certificate_signed",
+        Action.customer_information: "_send_customer_information",
+        Action.delete_certificate: "_send_delete_certificate",
+        Action.get_installed_certificate_ids: "_send_get_installed_certificate_ids",
+        Action.get_log: "_send_get_log",
+        Action.get_transaction_status: "_send_get_transaction_status",
+        Action.install_certificate: "_send_install_certificate",
+        Action.set_network_profile: "_send_set_network_profile",
+        Action.update_firmware: "_send_update_firmware",
+    }
+
     async def _send_command(self, command_name: Action):
         logger.debug("Sending OCPP command %s", command_name)
         try:
-            match command_name:
-                case Action.clear_cache:
-                    await self._send_clear_cache()
-                case Action.get_base_report:
-                    await self._send_get_base_report()
-                case Action.get_variables:
-                    await self._send_get_variables()
-                case Action.set_variables:
-                    await self._send_set_variables()
-                case Action.request_start_transaction:
-                    await self._send_request_start_transaction()
-                case Action.request_stop_transaction:
-                    await self._send_request_stop_transaction()
-                case Action.reset:
-                    await self._send_reset()
-                case Action.unlock_connector:
-                    await self._send_unlock_connector()
-                case Action.change_availability:
-                    await self._send_change_availability()
-                case Action.trigger_message:
-                    await self._send_trigger_message()
-                case Action.data_transfer:
-                    await self._send_data_transfer()
-                case Action.certificate_signed:
-                    await self._send_certificate_signed()
-                case Action.customer_information:
-                    await self._send_customer_information()
-                case Action.delete_certificate:
-                    await self._send_delete_certificate()
-                case Action.get_installed_certificate_ids:
-                    await self._send_get_installed_certificate_ids()
-                case Action.get_log:
-                    await self._send_get_log()
-                case Action.get_transaction_status:
-                    await self._send_get_transaction_status()
-                case Action.install_certificate:
-                    await self._send_install_certificate()
-                case Action.set_network_profile:
-                    await self._send_set_network_profile()
-                case Action.update_firmware:
-                    await self._send_update_firmware()
-                case _:
-                    logger.warning("Not supported command %s", command_name)
+            handler_name = self._COMMAND_HANDLERS.get(command_name)
+            if handler_name is not None:
+                await getattr(self, handler_name)()
+            else:
+                logger.warning("Not supported command %s", command_name)
         except TimeoutError:
             logger.error("Timeout waiting for %s response", command_name)
         except OCPPError as e:
@@ -804,46 +793,42 @@ def _parse_commands(commands_str: str) -> list[tuple[Action, float]]:
     return result
 
 
-def _parse_set_variable_specs(specs_str: str) -> list[dict]:
+def _parse_variable_specs(specs_str: str, require_value: bool = False) -> list[dict]:
     result = []
     for entry in specs_str.split(","):
         entry = entry.strip()
         if not entry:
             continue
-        if "=" not in entry or "." not in entry.split("=")[0]:
-            raise argparse.ArgumentTypeError(
-                f"Invalid variable spec '{entry}': expected 'Component.Variable=Value'"
-            )
-        component_var, value = entry.split("=", 1)
+        if require_value:
+            if "=" not in entry or "." not in entry.split("=")[0]:
+                raise argparse.ArgumentTypeError(
+                    f"Invalid variable spec '{entry}':"
+                    " expected 'Component.Variable=Value'"
+                )
+            component_var, value = entry.split("=", 1)
+        else:
+            if "." not in entry:
+                raise argparse.ArgumentTypeError(
+                    f"Invalid variable spec '{entry}': expected 'Component.Variable'"
+                )
+            component_var = entry
         component, variable = component_var.strip().split(".", 1)
-        result.append(
-            {
-                "component": {"name": component.strip()},
-                "variable": {"name": variable.strip()},
-                "attribute_value": value.strip(),
-            }
-        )
+        spec: dict = {
+            "component": {"name": component.strip()},
+            "variable": {"name": variable.strip()},
+        }
+        if require_value:
+            spec["attribute_value"] = value.strip()
+        result.append(spec)
     return result
 
 
+def _parse_set_variable_specs(specs_str: str) -> list[dict]:
+    return _parse_variable_specs(specs_str, require_value=True)
+
+
 def _parse_get_variable_specs(specs_str: str) -> list[dict]:
-    result = []
-    for entry in specs_str.split(","):
-        entry = entry.strip()
-        if not entry:
-            continue
-        if "." not in entry:
-            raise argparse.ArgumentTypeError(
-                f"Invalid variable spec '{entry}': expected 'Component.Variable'"
-            )
-        component, variable = entry.split(".", 1)
-        result.append(
-            {
-                "component": {"name": component.strip()},
-                "variable": {"name": variable.strip()},
-            }
-        )
-    return result
+    return _parse_variable_specs(specs_str, require_value=False)
 
 
 async def main():
index ecf8c177fb69be832e39bcdea007020b285a46df..8ae4699f85313b257cdd8ea2f85f15a64c3a44a7 100644 (file)
@@ -45,6 +45,7 @@ from ocpp.v201.enums import (
 from server import (
     DEFAULT_HEARTBEAT_INTERVAL,
     DEFAULT_TOTAL_COST,
+    FALLBACK_TRANSACTION_ID,
     MAX_REQUEST_ID,
     AuthConfig,
     AuthMode,
@@ -794,7 +795,7 @@ class TestTransactionTracking:
         )
         await command_charge_point._send_request_stop_transaction()
         request = command_charge_point.call.call_args[0][0]
-        assert request.transaction_id == "test_transaction_123"
+        assert request.transaction_id == FALLBACK_TRANSACTION_ID
 
     async def test_empty_transaction_id_not_stored(self, charge_point):
         await charge_point.on_transaction_event(
@@ -995,7 +996,7 @@ class TestOutgoingCommands:
         command_charge_point.call.assert_called_once()
         request = command_charge_point.call.call_args[0][0]
         assert isinstance(request, ocpp.v201.call.RequestStopTransaction)
-        assert request.transaction_id == "test_transaction_123"
+        assert request.transaction_id == FALLBACK_TRANSACTION_ID
 
     async def test_send_reset(self, command_charge_point):
         command_charge_point.call.return_value = ocpp.v201.call_result.Reset(
@@ -1123,7 +1124,7 @@ class TestOutgoingCommands:
         command_charge_point.call.assert_called_once()
         request = command_charge_point.call.call_args[0][0]
         assert isinstance(request, ocpp.v201.call.GetTransactionStatus)
-        assert request.transaction_id == "test_transaction_123"
+        assert request.transaction_id == FALLBACK_TRANSACTION_ID
 
     async def test_send_install_certificate(self, command_charge_point):
         command_charge_point.call.return_value = (
index 9365d72deaaba37e62e45da3b6d29f0949f9b7ee..be35e2d2324a4326b8201150038cb30196ffb3ab 100644 (file)
@@ -73,7 +73,7 @@ const toggleButtonId = computed(
 )
 
 const handleStartTransaction = async (): Promise<void> => {
-  const idTag = state.value.idTag.trim().length > 0 ? state.value.idTag.trim() : undefined
+  const idTag = state.value.idTag.length > 0 ? state.value.idTag : undefined
 
   if (!isOCPP20x.value && state.value.authorizeIdTag) {
     if (idTag == null) {
index b56319751eb31a158174cad6aeab0f34953d9821..ab40fd6225eeef26a91370c2debc782aa88d4dab 100644 (file)
 import { ref } from 'vue'
 
 import Button from '@/components/buttons/Button.vue'
-import { getFromLocalStorage, setToLocalStorage } from '@/composables'
+import {
+  getFromLocalStorage,
+  setToLocalStorage,
+  SHARED_TOGGLE_BUTTON_KEY_PREFIX,
+  TOGGLE_BUTTON_KEY_PREFIX,
+} from '@/composables'
 
 const props = defineProps<{
   id: string
@@ -23,7 +28,7 @@ const props = defineProps<{
 
 const $emit = defineEmits(['clicked'])
 
-const id = props.shared === true ? `shared-toggle-button-${props.id}` : `toggle-button-${props.id}`
+const id = props.shared === true ? `${SHARED_TOGGLE_BUTTON_KEY_PREFIX}${props.id}` : `${TOGGLE_BUTTON_KEY_PREFIX}${props.id}`
 
 const state = ref<{ status: boolean }>({
   status: getFromLocalStorage<boolean>(id, props.status ?? false),
@@ -32,7 +37,7 @@ const state = ref<{ status: boolean }>({
 const click = (): void => {
   if (props.shared === true) {
     for (const key in localStorage) {
-      if (key !== id && key.startsWith('shared-toggle-button-')) {
+      if (key !== id && key.startsWith(SHARED_TOGGLE_BUTTON_KEY_PREFIX)) {
         setToLocalStorage<boolean>(key, false)
         state.value.status = getFromLocalStorage<boolean>(key, false)
       }
index cb51e7d5068bc79b2082f3b6a286355272196dc0..d9430a5a321daa71a4754030f658098943958da5 100644 (file)
@@ -10,7 +10,7 @@
       {{ getSupervisionUrl() }}
     </td>
     <td class="cs-table__column">
-      {{ getWSState() }}
+      {{ getWebSocketStateName(chargingStation.wsState) }}
     </td>
     <td class="cs-table__column">
       {{ chargingStation.bootNotificationResponse?.status ?? 'Ø' }}
@@ -144,8 +144,8 @@ import StateButton from '@/components/buttons/StateButton.vue'
 import ToggleButton from '@/components/buttons/ToggleButton.vue'
 import CSConnector from '@/components/charging-stations/CSConnector.vue'
 import {
-  deleteFromLocalStorage,
-  getLocalStorage,
+  deleteLocalStorageByKeyPattern,
+  getWebSocketStateName,
   useExecuteAction,
   useUIClient,
 } from '@/composables'
@@ -192,20 +192,6 @@ const getSupervisionUrl = (): string => {
   const supervisionUrl = new URL(props.chargingStation.supervisionUrl)
   return `${supervisionUrl.protocol}//${supervisionUrl.host.split('.').join('.\u200b')}`
 }
-const getWSState = (): string => {
-  switch (props.chargingStation?.wsState) {
-    case WebSocket.CLOSED:
-      return 'Closed'
-    case WebSocket.CLOSING:
-      return 'Closing'
-    case WebSocket.CONNECTING:
-      return 'Connecting'
-    case WebSocket.OPEN:
-      return 'Open'
-    default:
-      return 'Ø'
-  }
-}
 
 const $uiClient = useUIClient()
 
@@ -245,11 +231,7 @@ const deleteChargingStation = (): void => {
   $uiClient
     .deleteChargingStation(props.chargingStation.stationInfo.hashId)
     .then(() => {
-      for (const key in getLocalStorage()) {
-        if (key.includes(props.chargingStation.stationInfo.hashId)) {
-          deleteFromLocalStorage(key)
-        }
-      }
+      deleteLocalStorageByKeyPattern(props.chargingStation.stationInfo.hashId)
       $emit('need-refresh')
       return $toast.success('Charging station successfully deleted')
     })
index 5079f3d82c7a1cea5ba0ef30afd0f4a5480340eb..152907cc9855d2ddd20cfc8da9e562dff2ca653c 100644 (file)
@@ -1,3 +1,6 @@
 // Local UI project constants
 
+export const SHARED_TOGGLE_BUTTON_KEY_PREFIX = 'shared-toggle-button-'
+export const TOGGLE_BUTTON_KEY_PREFIX = 'toggle-button-'
+export const UI_SERVER_CONFIGURATION_INDEX_KEY = 'uiServerConfigurationIndex'
 export const UI_WEBSOCKET_REQUEST_TIMEOUT_MS = 60_000
index fbc719802bc50bb8e5188f4aa7c74f88786f2baf..99a4ee51100a7fa10c5955dc2e19d10a2a240433 100644 (file)
@@ -5,6 +5,7 @@ import { useToast } from 'vue-toast-notification'
 
 import type { ChargingStationData, ConfigurationData, UUIDv4 } from '@/types'
 
+import { SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX } from './Constants'
 import { UIClient } from './UIClient'
 
 export const configurationKey: InjectionKey<Ref<ConfigurationData>> = Symbol('configuration')
@@ -68,13 +69,51 @@ export const getLocalStorage = (): Storage => {
   return localStorage
 }
 
+/**
+ * Deletes all localStorage entries whose key includes the given pattern.
+ * @param pattern - Substring to match against localStorage keys
+ */
+export const deleteLocalStorageByKeyPattern = (pattern: string): void => {
+  const keysToDelete: string[] = []
+  for (const key in getLocalStorage()) {
+    if (key.includes(pattern)) {
+      keysToDelete.push(key)
+    }
+  }
+  for (const key of keysToDelete) {
+    deleteFromLocalStorage(key)
+  }
+}
+
+/**
+ * Returns a human-readable name for a WebSocket ready state.
+ * @param state - The WebSocket readyState value
+ * @returns The state name or 'Ø' for unknown/undefined states
+ */
+export const getWebSocketStateName = (state: number | undefined): string => {
+  switch (state) {
+    case WebSocket.CLOSED:
+      return 'Closed'
+    case WebSocket.CLOSING:
+      return 'Closing'
+    case WebSocket.CONNECTING:
+      return 'Connecting'
+    case WebSocket.OPEN:
+      return 'Open'
+    default:
+      return 'Ø'
+  }
+}
+
 /**
  * Resets the state of a toggle button by removing its entry from localStorage.
  * @param id - The identifier of the toggle button
  * @param shared - Whether the toggle button is shared
  */
 export const resetToggleButtonState = (id: string, shared = false): void => {
-  const key = shared ? `shared-toggle-button-${id}` : `toggle-button-${id}`
+  const key = shared
+    ? `${SHARED_TOGGLE_BUTTON_KEY_PREFIX}${id}`
+    : `${TOGGLE_BUTTON_KEY_PREFIX}${id}`
   deleteFromLocalStorage(key)
 }
 
index d7cf7eff18cb52c690ed19adf43324783567ecc8..a9543fa79f0d6937db7b41994c1bcfb7aee2ef58 100644 (file)
@@ -1,3 +1,8 @@
+export {
+  SHARED_TOGGLE_BUTTON_KEY_PREFIX,
+  TOGGLE_BUTTON_KEY_PREFIX,
+  UI_SERVER_CONFIGURATION_INDEX_KEY,
+} from './Constants'
 export { UIClient } from './UIClient'
 export {
   chargingStationsKey,
@@ -5,8 +10,10 @@ export {
   convertToBoolean,
   convertToInt,
   deleteFromLocalStorage,
+  deleteLocalStorageByKeyPattern,
   getFromLocalStorage,
   getLocalStorage,
+  getWebSocketStateName,
   randomUUID,
   resetToggleButtonState,
   setToLocalStorage,
index 87623609f5e0bd37222f11a27c8a9c454cd9d550..55c0419e8f40c605a408678a0145fedeb1804758 100644 (file)
@@ -13,7 +13,7 @@
           @change="
             () => {
               if (
-                getFromLocalStorage<number>('uiServerConfigurationIndex', 0) !== state.uiServerIndex
+                getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0) !== state.uiServerIndex
               ) {
                 $uiClient.setConfiguration(
                   ($configuration.uiServer as UIServerConfigurationSection[])[state.uiServerIndex]
@@ -22,7 +22,7 @@
                 $uiClient.registerWSEventListener(
                   'open',
                   () => {
-                    setToLocalStorage<number>('uiServerConfigurationIndex', state.uiServerIndex)
+                    setToLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, state.uiServerIndex)
                     clearToggleButtons()
                     refresh()
                     $route.name !== 'charging-stations' &&
                   'error',
                   () => {
                     state.uiServerIndex = getFromLocalStorage<number>(
-                      'uiServerConfigurationIndex',
+                      UI_SERVER_CONFIGURATION_INDEX_KEY,
                       0
                     )
                     $uiClient.setConfiguration(
                       ($configuration.uiServer as UIServerConfigurationSection[])[
-                        getFromLocalStorage<number>('uiServerConfigurationIndex', 0)
+                        getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0)
                       ]
                     )
                     registerWSEventListeners()
@@ -116,11 +116,11 @@ import ToggleButton from '@/components/buttons/ToggleButton.vue'
 import CSTable from '@/components/charging-stations/CSTable.vue'
 import Container from '@/components/Container.vue'
 import {
-  deleteFromLocalStorage,
+  deleteLocalStorageByKeyPattern,
   getFromLocalStorage,
-  getLocalStorage,
   randomUUID,
   setToLocalStorage,
+  UI_SERVER_CONFIGURATION_INDEX_KEY,
   useChargingStations,
   useConfiguration,
   useTemplates,
@@ -149,7 +149,7 @@ const state = ref<{
   gettingTemplates: false,
   renderAddChargingStations: randomUUID(),
   renderChargingStations: randomUUID(),
-  uiServerIndex: getFromLocalStorage<number>('uiServerConfigurationIndex', 0),
+  uiServerIndex: getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0),
 })
 
 const refresh = (): void => {
@@ -158,11 +158,7 @@ const refresh = (): void => {
 }
 
 const clearToggleButtons = (): void => {
-  for (const key in getLocalStorage()) {
-    if (key.includes('toggle-button')) {
-      deleteFromLocalStorage(key)
-    }
-  }
+  deleteLocalStorageByKeyPattern('toggle-button')
 }
 
 const $configuration = useConfiguration()