virtual-drive 0.1.0__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.
Files changed (68) hide show
  1. virtual_drive/__init__.py +9 -0
  2. virtual_drive/_version.py +34 -0
  3. virtual_drive/adapters/__init__.py +8 -0
  4. virtual_drive/adapters/dispatcher.py +83 -0
  5. virtual_drive/adapters/mcb.py +181 -0
  6. virtual_drive/adapters/protocol.py +169 -0
  7. virtual_drive/adapters/sdo.py +194 -0
  8. virtual_drive/constants.py +43 -0
  9. virtual_drive/core.py +69 -0
  10. virtual_drive/enums.py +22 -0
  11. virtual_drive/environment.py +86 -0
  12. virtual_drive/exceptions.py +79 -0
  13. virtual_drive/py.typed +0 -0
  14. virtual_drive/register_service/__init__.py +15 -0
  15. virtual_drive/register_service/broker.py +59 -0
  16. virtual_drive/register_service/modules/__init__.py +45 -0
  17. virtual_drive/register_service/modules/capture/__init__.py +5 -0
  18. virtual_drive/register_service/modules/capture/base.py +279 -0
  19. virtual_drive/register_service/modules/capture/disturbance.py +326 -0
  20. virtual_drive/register_service/modules/capture/monitoring.py +423 -0
  21. virtual_drive/register_service/modules/capture/monitoring_v1.py +41 -0
  22. virtual_drive/register_service/modules/encoders/__init__.py +17 -0
  23. virtual_drive/register_service/modules/encoders/absolute_encoder_1.py +47 -0
  24. virtual_drive/register_service/modules/encoders/absolute_encoder_2.py +47 -0
  25. virtual_drive/register_service/modules/encoders/base_encoder.py +25 -0
  26. virtual_drive/register_service/modules/encoders/halls_encoder.py +59 -0
  27. virtual_drive/register_service/modules/encoders/incremental_encoder_1.py +49 -0
  28. virtual_drive/register_service/modules/encoders/incremental_encoder_2.py +48 -0
  29. virtual_drive/register_service/modules/encoders/secondary_ssi_encoder.py +47 -0
  30. virtual_drive/register_service/modules/feedbacks/__init__.py +0 -0
  31. virtual_drive/register_service/modules/feedbacks/commutation_feedback.py +63 -0
  32. virtual_drive/register_service/modules/feedbacks/position_feedback.py +46 -0
  33. virtual_drive/register_service/modules/gpios.py +76 -0
  34. virtual_drive/register_service/modules/identification.py +70 -0
  35. virtual_drive/register_service/modules/internal_generator.py +349 -0
  36. virtual_drive/register_service/modules/operation_mode.py +119 -0
  37. virtual_drive/register_service/modules/phasing.py +578 -0
  38. virtual_drive/register_service/modules/plants/__init__.py +1 -0
  39. virtual_drive/register_service/modules/plants/base_signal_register.py +423 -0
  40. virtual_drive/register_service/modules/plants/current_loop_rate.py +58 -0
  41. virtual_drive/register_service/modules/plants/plant_classes.py +121 -0
  42. virtual_drive/register_service/modules/plants/plant_module_bases.py +434 -0
  43. virtual_drive/register_service/modules/plants/plant_signal_handler.py +282 -0
  44. virtual_drive/register_service/modules/plants/position_plant_modules.py +119 -0
  45. virtual_drive/register_service/modules/plants/rl_direct_plant_modules.py +111 -0
  46. virtual_drive/register_service/modules/plants/rl_quadrature_plant_modules.py +111 -0
  47. virtual_drive/register_service/modules/plants/velocity_plant_modules.py +114 -0
  48. virtual_drive/register_service/modules/plants/voltage_to_current_plant_modules.py +59 -0
  49. virtual_drive/register_service/modules/plants/voltage_to_velocity_plant_module.py +58 -0
  50. virtual_drive/register_service/modules/state_control.py +110 -0
  51. virtual_drive/register_service/modules/storage.py +128 -0
  52. virtual_drive/register_service/modules/virtual_table.py +110 -0
  53. virtual_drive/register_service/owner_registry.py +30 -0
  54. virtual_drive/register_service/register_value.py +3 -0
  55. virtual_drive/register_service/service.py +761 -0
  56. virtual_drive/register_service/unowned_bank.py +35 -0
  57. virtual_drive/register_service/virtual_register.py +82 -0
  58. virtual_drive/resources/__init__.py +8 -0
  59. virtual_drive/resources/virtual_drive.xcf +323 -0
  60. virtual_drive/resources/virtual_drive.xdf +3734 -0
  61. virtual_drive/resources/virtual_drive_canopen.xdf +5008 -0
  62. virtual_drive/resources/virtual_drive_v3.0.xdf +4130 -0
  63. virtual_drive/signals.py +103 -0
  64. virtual_drive-0.1.0.dist-info/LICENSE.md +157 -0
  65. virtual_drive-0.1.0.dist-info/METADATA +221 -0
  66. virtual_drive-0.1.0.dist-info/RECORD +68 -0
  67. virtual_drive-0.1.0.dist-info/WHEEL +5 -0
  68. virtual_drive-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ try:
2
+ from ._version import __version__ # noqa: F401
3
+ except ModuleNotFoundError:
4
+ __version__ = "development"
5
+
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ ]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.0'
32
+ __version_tuple__ = version_tuple = (0, 1, 0)
33
+
34
+ __commit_id__ = commit_id = 'gb9be2494f'
@@ -0,0 +1,8 @@
1
+ """Protocol adapters implemented by the runtime."""
2
+
3
+ from virtual_drive.adapters.dispatcher import FrameDispatcher
4
+ from virtual_drive.adapters.mcb import MCBAdapter
5
+ from virtual_drive.adapters.protocol import ProtocolAdapter
6
+ from virtual_drive.adapters.sdo import SDOAdapter
7
+
8
+ __all__ = ["FrameDispatcher", "MCBAdapter", "SDOAdapter", "ProtocolAdapter"]
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ingenialink.dictionary import Interface
6
+
7
+ from virtual_drive.adapters.mcb import MCBAdapter
8
+ from virtual_drive.adapters.protocol import (
9
+ OperationResult,
10
+ ProtocolAdapter,
11
+ )
12
+ from virtual_drive.adapters.sdo import SDOAdapter
13
+
14
+ if TYPE_CHECKING:
15
+ from virtual_drive.register_service import RegisterService
16
+
17
+
18
+ class FrameDispatcher:
19
+ """Dispatch incoming frames through a configured protocol adapter."""
20
+
21
+ def __init__(
22
+ self,
23
+ register_service: RegisterService,
24
+ protocol: Interface = Interface.ETH,
25
+ adapter: ProtocolAdapter | None = None,
26
+ ) -> None:
27
+ self._register_service = register_service
28
+ self._adapter = adapter if adapter is not None else self._build_adapter(protocol)
29
+
30
+ def dispatch(self, frame: bytes) -> bytes | None:
31
+ """Return the response for a frame, or ``None`` if it cannot be handled.
32
+
33
+ Args:
34
+ frame: Incoming frame to be dispatched.
35
+
36
+ Returns:
37
+ The response frame, or ``None`` if the frame cannot be handled.
38
+
39
+ """
40
+ try:
41
+ request = self._adapter.parse_request(frame)
42
+ except Exception:
43
+ return None
44
+
45
+ if self._adapter.is_unknown_operation(request):
46
+ result = OperationResult(success=False, value=None)
47
+ return self._adapter.build_response(request, result)
48
+ register = None
49
+ operation_error = None
50
+ try:
51
+ register = self._adapter.resolve_register(self._register_service, request)
52
+ if self._adapter.is_write_operation(request):
53
+ value = self._adapter.decode_write_value(register, request)
54
+ self._register_service.write(register, value)
55
+ result = OperationResult(success=True, value=None)
56
+ else:
57
+ value = self._register_service.read(register)
58
+ result = OperationResult(success=True, value=value)
59
+ except Exception as error:
60
+ operation_error = error
61
+ result = OperationResult(success=False, value=None)
62
+
63
+ return self._adapter.build_response(request, result, register, operation_error)
64
+
65
+ @staticmethod
66
+ def _build_adapter(protocol: Interface) -> ProtocolAdapter:
67
+ """Instantiate the adapter for the selected protocol.
68
+
69
+ Args:
70
+ protocol: Interface value used to select the protocol adapter.
71
+
72
+ Returns:
73
+ ProtocolAdapter: Adapter instance for the configured protocol.
74
+
75
+ Raises:
76
+ ValueError: If the interface is not supported.
77
+
78
+ """
79
+ if protocol == Interface.ETH:
80
+ return MCBAdapter()
81
+ if protocol in (Interface.ECAT, Interface.CAN):
82
+ return SDOAdapter()
83
+ raise ValueError(f"Unsupported protocol interface '{protocol.name}'.")
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ingenialink.utils._utils import convert_bytes_to_dtype, convert_dtype_to_bytes
7
+ from ingenialink.utils.mcb import MCB
8
+
9
+ if TYPE_CHECKING:
10
+ from ingenialink.register import Register
11
+
12
+ from virtual_drive.register_service import RegisterService
13
+ from virtual_drive.register_service.register_value import RegisterValue
14
+
15
+ from virtual_drive.adapters.protocol import (
16
+ MCBOperationRequest,
17
+ MCBOperationType,
18
+ OperationRequest,
19
+ OperationResult,
20
+ ProtocolAdapter,
21
+ )
22
+ from virtual_drive.exceptions import OPERATION_NOT_EXECUTED_ERROR_CODE, VirtualDriveError
23
+
24
+
25
+ class MCBCommand(Enum):
26
+ """Enum of the MCB commands."""
27
+
28
+ READ = 1
29
+ WRITE = 2
30
+ ACK = 3
31
+ ERROR_READ = 5
32
+ ERROR_WRITE = 6
33
+
34
+
35
+ class MCBAdapter(ProtocolAdapter):
36
+ """Simple adapter that understands the IngeniaLink MCB/UDP protocol."""
37
+
38
+ def parse_request(self, frame: bytes) -> OperationRequest:
39
+ """Parse the MCB request frame into an operation request.
40
+
41
+ Returns:
42
+ OperationRequest: Parsed operation request data.
43
+
44
+ """
45
+ address, axis, command, data = MCB.read_mcb_frame(frame)
46
+ if command == MCBCommand.WRITE.value:
47
+ operation = MCBOperationType.WRITE
48
+ elif command == MCBCommand.READ.value:
49
+ operation = MCBOperationType.READ
50
+ else:
51
+ operation = MCBOperationType.UNKNOWN
52
+ return MCBOperationRequest(axis=axis, address=address, operation=operation, data=data)
53
+
54
+ def is_unknown_operation(self, request: OperationRequest) -> bool:
55
+ """Return whether the request operation is unsupported."""
56
+ mcb_request = self._as_mcb_request(request)
57
+ return mcb_request.operation is MCBOperationType.UNKNOWN
58
+
59
+ def is_write_operation(self, request: OperationRequest) -> bool:
60
+ """Return whether the request operation writes a register value."""
61
+ mcb_request = self._as_mcb_request(request)
62
+ return mcb_request.operation is MCBOperationType.WRITE
63
+
64
+ def resolve_register(
65
+ self,
66
+ register_service: RegisterService,
67
+ request: OperationRequest,
68
+ ) -> Register:
69
+ """Resolve the addressed register from an MCB request.
70
+
71
+ Returns:
72
+ Register: Register identified by axis/address in the request.
73
+
74
+ """
75
+ mcb_request = self._as_mcb_request(request)
76
+ return register_service.get_register_by_address(mcb_request.axis, mcb_request.address)
77
+
78
+ def decode_write_value(self, register: Register, request: OperationRequest) -> RegisterValue:
79
+ """Decode the write payload into a register value.
80
+
81
+ Returns:
82
+ RegisterValue: The decoded register value.
83
+
84
+ """
85
+ return convert_bytes_to_dtype(request.data, register.dtype)
86
+
87
+ def build_response(
88
+ self,
89
+ request: OperationRequest,
90
+ result: OperationResult,
91
+ register: Register | None = None,
92
+ error: Exception | None = None,
93
+ ) -> bytes:
94
+ """Return the response frame for the provided MCB request."""
95
+ mcb_request = self._as_mcb_request(request)
96
+ axis, address = mcb_request.axis, mcb_request.address
97
+ if not result.success:
98
+ return self._build_error_frame(mcb_request, error)
99
+ if mcb_request.operation is MCBOperationType.WRITE:
100
+ payload = mcb_request.data[:8]
101
+ return MCB.build_mcb_frame(MCBCommand.ACK.value, axis, address, payload)
102
+ if register is None or result.value is None:
103
+ return self._build_error_frame(mcb_request, None)
104
+ payload = self._encode_read_value(register, result.value)
105
+ return MCB.build_mcb_frame(MCBCommand.ACK.value, axis, address, payload)
106
+
107
+ @staticmethod
108
+ def _as_mcb_request(request: OperationRequest) -> MCBOperationRequest:
109
+ if not isinstance(request, MCBOperationRequest):
110
+ raise TypeError("MCBAdapter requires MCBOperationRequest")
111
+ return request
112
+
113
+ @staticmethod
114
+ def _encode_read_value(register: Register, value: RegisterValue) -> bytes:
115
+ if isinstance(value, bytes):
116
+ return value
117
+ return convert_dtype_to_bytes(value, register.dtype)
118
+
119
+ def _build_error_frame(
120
+ self,
121
+ request: MCBOperationRequest,
122
+ error: Exception | None,
123
+ ) -> bytes:
124
+ """Build an MCB error response with an embedded protocol error code.
125
+
126
+ Args:
127
+ request: Original parsed MCB request.
128
+ error: The operation error that triggered the failure, if any.
129
+
130
+ Returns:
131
+ bytes: MCB error frame with the 32-bit error code in config payload.
132
+
133
+ """
134
+ command = self._resolve_error_command(request.operation)
135
+ payload = self._build_error_payload(self._resolve_error_code(error))
136
+ return MCB.build_mcb_frame(command.value, request.axis, request.address, payload)
137
+
138
+ @staticmethod
139
+ def _resolve_error_command(operation: MCBOperationType) -> MCBCommand:
140
+ """Resolve the MCB error command to be used in the error response frame.
141
+
142
+ Args:
143
+ operation: The type of the original request operation that failed.
144
+
145
+ Returns:
146
+ The corresponding MCB error command for the failed operation.
147
+
148
+ """
149
+ if operation is MCBOperationType.WRITE:
150
+ return MCBCommand.ERROR_WRITE
151
+ return MCBCommand.ERROR_READ
152
+
153
+ @staticmethod
154
+ def _build_error_payload(error_code: int) -> bytes:
155
+ """Build the MCB error frame payload containing the 32-bit error code.
156
+
157
+ Args:
158
+ error_code: The protocol-specific error code to be embedded in the frame.
159
+
160
+ Returns:
161
+ A bytes object of length 8, where the first 4 bytes represent the error code
162
+ in little-endian format, and the remaining 4 bytes are padding.
163
+
164
+ """
165
+ return error_code.to_bytes(8, byteorder="little", signed=False)
166
+
167
+ @staticmethod
168
+ def _resolve_error_code(error: Exception | None) -> int:
169
+ """Resolve the protocol error code to be embedded in the MCB error frame.
170
+
171
+ Args:
172
+ error: The original exception that caused the operation to fail, if any.
173
+
174
+ Returns:
175
+ The protocol-specific error code extracted from the exception, or a default
176
+ code if the error is not a VirtualDriveError or is None.
177
+
178
+ """
179
+ if isinstance(error, VirtualDriveError):
180
+ return error.error_code
181
+ return OPERATION_NOT_EXECUTED_ERROR_CODE
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING, Protocol, Union
6
+
7
+ if TYPE_CHECKING:
8
+ from ingenialink.register import Register
9
+
10
+ from virtual_drive.register_service import RegisterService
11
+ from virtual_drive.register_service.register_value import RegisterValue
12
+
13
+
14
+ class MCBOperationType(Enum):
15
+ """Register operations represented by incoming MCB frames."""
16
+
17
+ READ = "read"
18
+ WRITE = "write"
19
+ UNKNOWN = "unknown"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class MCBOperationRequest:
24
+ """Operation extracted from an MCB protocol frame.
25
+
26
+ Attributes:
27
+ axis: Target axis/subnode from the frame.
28
+ address: Register address to access.
29
+ operation: Register operation to execute.
30
+ data: Raw protocol payload associated with the request.
31
+
32
+ """
33
+
34
+ axis: int
35
+ address: int
36
+ operation: MCBOperationType
37
+ data: bytes
38
+
39
+
40
+ class SDOOperationType(Enum):
41
+ """Register operations represented by incoming SDO frames."""
42
+
43
+ READ = "read"
44
+ WRITE = "write"
45
+ UNKNOWN = "unknown"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class SDOOperationRequest:
50
+ """Operation extracted from an SDO protocol frame.
51
+
52
+ Attributes:
53
+ index: Object dictionary index.
54
+ subindex: Object dictionary subindex.
55
+ operation: Register operation to execute.
56
+ data: Raw protocol payload associated with the request.
57
+
58
+ """
59
+
60
+ index: int
61
+ subindex: int
62
+ operation: SDOOperationType
63
+ data: bytes
64
+
65
+
66
+ OperationRequest = Union[MCBOperationRequest, SDOOperationRequest]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class OperationResult:
71
+ """Result of executing a normalized register operation.
72
+
73
+ Attributes:
74
+ success: Whether the operation completed successfully.
75
+ value: Read value for successful read operations, otherwise ``None``.
76
+
77
+ """
78
+
79
+ success: bool
80
+ value: RegisterValue | None
81
+
82
+
83
+ class ProtocolAdapter(Protocol):
84
+ """Adapter contract for translating frames to/from register operations.
85
+
86
+ Implementations are responsible for protocol-specific parsing, payload
87
+ conversion, and response encoding.
88
+
89
+ """
90
+
91
+ def parse_request(self, frame: bytes) -> OperationRequest:
92
+ """Decode ``frame`` into a normalized :class:`OperationRequest`."""
93
+ ...
94
+
95
+ def is_unknown_operation(self, request: OperationRequest) -> bool:
96
+ """Return whether ``request`` represents an unsupported operation."""
97
+ ...
98
+
99
+ def is_write_operation(self, request: OperationRequest) -> bool:
100
+ """Return whether ``request`` represents a write operation."""
101
+ ...
102
+
103
+ def resolve_register(
104
+ self,
105
+ register_service: RegisterService,
106
+ request: OperationRequest,
107
+ ) -> Register:
108
+ """Resolve the target register for ``request`` using ``register_service``."""
109
+ ...
110
+
111
+ def decode_write_value(self, register: Register, request: OperationRequest) -> RegisterValue:
112
+ """Decode the write payload in ``request`` into a register value.
113
+
114
+ Args:
115
+ register: Target register metadata (dtype, etc.) used for decoding.
116
+ request: Parsed write request containing protocol payload bytes.
117
+
118
+ Returns:
119
+ RegisterValue: Value ready to be persisted by the register service.
120
+
121
+ """
122
+ ...
123
+
124
+ def build_response(
125
+ self,
126
+ request: OperationRequest,
127
+ result: OperationResult,
128
+ register: Register | None = None,
129
+ error: Exception | None = None,
130
+ ) -> bytes:
131
+ """Encode an outgoing response frame for ``request``.
132
+
133
+ Args:
134
+ request: Original normalized request that triggered the operation.
135
+ result: Execution outcome used to build ACK/NACK and payload.
136
+ register: Register metadata, when needed to encode result values.
137
+ error: Original execution error, when operation failed.
138
+
139
+ Returns:
140
+ bytes: Protocol-specific response frame.
141
+
142
+ """
143
+ ...
144
+
145
+
146
+ def is_unknown_operation(request: OperationRequest) -> bool:
147
+ """Return ``True`` when the request operation is not supported."""
148
+ if isinstance(request, MCBOperationRequest):
149
+ return (
150
+ isinstance(request.operation, MCBOperationType)
151
+ and request.operation is MCBOperationType.UNKNOWN
152
+ )
153
+ return (
154
+ isinstance(request.operation, SDOOperationType)
155
+ and request.operation is SDOOperationType.UNKNOWN
156
+ )
157
+
158
+
159
+ def is_write_operation(request: OperationRequest) -> bool:
160
+ """Return ``True`` when the request is a write operation."""
161
+ if isinstance(request, MCBOperationRequest):
162
+ return (
163
+ isinstance(request.operation, MCBOperationType)
164
+ and request.operation is MCBOperationType.WRITE
165
+ )
166
+ return (
167
+ isinstance(request.operation, SDOOperationType)
168
+ and request.operation is SDOOperationType.WRITE
169
+ )
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from ingenialink.utils._utils import convert_bytes_to_dtype, convert_dtype_to_bytes
6
+
7
+ if TYPE_CHECKING:
8
+ from ingenialink.register import Register
9
+
10
+ from virtual_drive.register_service import RegisterService
11
+ from virtual_drive.register_service.register_value import RegisterValue
12
+
13
+ from ingenialink.virtual.codec import deserialize_sdo_frame, serialize_sdo_frame
14
+
15
+ from virtual_drive.adapters.protocol import (
16
+ OperationRequest,
17
+ OperationResult,
18
+ ProtocolAdapter,
19
+ SDOOperationRequest,
20
+ SDOOperationType,
21
+ )
22
+ from virtual_drive.exceptions import (
23
+ OPERATION_NOT_EXECUTED_ERROR_CODE,
24
+ InvalidCommandError,
25
+ VirtualDriveError,
26
+ )
27
+
28
+
29
+ class SDOAdapter(ProtocolAdapter):
30
+ """Adapter for serialized SDO read/write JSON frames."""
31
+
32
+ def parse_request(self, frame: bytes) -> OperationRequest:
33
+ """Parse a serialized SDO JSON request frame.
34
+
35
+ Args:
36
+ frame: Serialized request frame.
37
+
38
+ Returns:
39
+ OperationRequest: Parsed and normalized operation request.
40
+
41
+ Raises:
42
+ InvalidCommandError: If the frame is not a valid serialized SDO request
43
+ or if the payload type is invalid.
44
+
45
+ """
46
+ try:
47
+ request = deserialize_sdo_frame(frame)
48
+ except (UnicodeDecodeError, ValueError, TypeError) as e:
49
+ raise InvalidCommandError("Invalid serialized SDO frame") from e
50
+
51
+ command = request.get("command")
52
+ if command == "read":
53
+ operation = SDOOperationType.READ
54
+ elif command == "write":
55
+ operation = SDOOperationType.WRITE
56
+ else:
57
+ operation = SDOOperationType.UNKNOWN
58
+
59
+ index, has_valid_index = self._parse_object_address_field(request, "index")
60
+ subindex, has_valid_subindex = self._parse_object_address_field(request, "subindex")
61
+ if not (has_valid_index and has_valid_subindex):
62
+ operation = SDOOperationType.UNKNOWN
63
+
64
+ payload: bytes
65
+ raw_data = request.get("data", b"")
66
+ if raw_data is None:
67
+ payload = b""
68
+ elif isinstance(raw_data, bytes):
69
+ payload = raw_data
70
+ elif isinstance(raw_data, bytearray):
71
+ payload = bytes(raw_data)
72
+ else:
73
+ raise InvalidCommandError("SDO write payload must be bytes")
74
+
75
+ return SDOOperationRequest(
76
+ index=index,
77
+ subindex=subindex,
78
+ operation=operation,
79
+ data=payload,
80
+ )
81
+
82
+ def is_unknown_operation(self, request: OperationRequest) -> bool:
83
+ """Return whether the request operation is unsupported."""
84
+ sdo_request = self._as_sdo_request(request)
85
+ return sdo_request.operation is SDOOperationType.UNKNOWN
86
+
87
+ def is_write_operation(self, request: OperationRequest) -> bool:
88
+ """Return whether the request operation writes a register value."""
89
+ sdo_request = self._as_sdo_request(request)
90
+ return sdo_request.operation is SDOOperationType.WRITE
91
+
92
+ def resolve_register(
93
+ self,
94
+ register_service: RegisterService,
95
+ request: OperationRequest,
96
+ ) -> Register:
97
+ """Resolve the target register from an SDO request.
98
+
99
+ Returns:
100
+ Register: Register identified by index/subindex in the request.
101
+
102
+ """
103
+ sdo_request = self._as_sdo_request(request)
104
+ return register_service.get_register_by_index_subindex(
105
+ index=sdo_request.index,
106
+ subindex=sdo_request.subindex,
107
+ )
108
+
109
+ def decode_write_value(self, register: Register, request: OperationRequest) -> RegisterValue:
110
+ """Decode the write payload into a register value.
111
+
112
+ Args:
113
+ register: Register metadata used for dtype conversion.
114
+ request: Parsed request containing the write payload bytes.
115
+
116
+ Returns:
117
+ RegisterValue: Value converted to the register dtype.
118
+
119
+ """
120
+ return convert_bytes_to_dtype(request.data, register.dtype)
121
+
122
+ def build_response(
123
+ self,
124
+ request: OperationRequest,
125
+ result: OperationResult,
126
+ register: Register | None = None,
127
+ error: Exception | None = None,
128
+ ) -> bytes:
129
+ """Build a serialized SDO JSON response frame.
130
+
131
+ Args:
132
+ request: Original parsed operation request.
133
+ result: Operation outcome.
134
+ register: Register metadata used to encode read values.
135
+ error: Original execution error, when operation fails.
136
+
137
+ Returns:
138
+ bytes: Serialized response payload.
139
+
140
+ """
141
+ sdo_request = self._as_sdo_request(request)
142
+ response: dict[str, Any] = {
143
+ "command": sdo_request.operation.value,
144
+ "index": sdo_request.index,
145
+ "subindex": sdo_request.subindex,
146
+ }
147
+ if not result.success:
148
+ response["error_code"] = self._resolve_error_code(error)
149
+ return serialize_sdo_frame(response)
150
+
151
+ if sdo_request.operation is SDOOperationType.READ:
152
+ if register is None or result.value is None:
153
+ response["error_code"] = OPERATION_NOT_EXECUTED_ERROR_CODE
154
+ return serialize_sdo_frame(response)
155
+ response["data"] = self._encode_read_value(register, result.value)
156
+ return serialize_sdo_frame(response)
157
+
158
+ @staticmethod
159
+ def _as_sdo_request(request: OperationRequest) -> SDOOperationRequest:
160
+ if not isinstance(request, SDOOperationRequest):
161
+ raise TypeError("SDOAdapter requires SDOOperationRequest")
162
+ return request
163
+
164
+ @staticmethod
165
+ def _parse_object_address_field(request: dict[str, Any], key: str) -> tuple[int, bool]:
166
+ raw_value = request.get(key)
167
+ if raw_value is None:
168
+ return 0, False
169
+ try:
170
+ return int(raw_value), True
171
+ except (TypeError, ValueError):
172
+ return 0, False
173
+
174
+ @staticmethod
175
+ def _encode_read_value(register: Register, value: RegisterValue) -> bytes:
176
+ if isinstance(value, bytes):
177
+ return value
178
+ return convert_dtype_to_bytes(value, register.dtype)
179
+
180
+ @staticmethod
181
+ def _resolve_error_code(error: Exception | None) -> int:
182
+ """Resolve the protocol error code to be embedded in the SDO response.
183
+
184
+ Args:
185
+ error: The original exception that caused the operation to fail, if any.
186
+
187
+ Returns:
188
+ The protocol-specific error code extracted from the exception, or a default
189
+ code if the error is not a VirtualDriveError or is None.
190
+
191
+ """
192
+ if isinstance(error, VirtualDriveError):
193
+ return error.error_code
194
+ return OPERATION_NOT_EXECUTED_ERROR_CODE