masterk-cnet 1.0.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.
@@ -0,0 +1,38 @@
1
+ """K200S CNET communication library.
2
+
3
+ Example:
4
+ >>> from masterk_cnet import CnetConfig
5
+ >>> CnetConfig(station=1).station
6
+ 1
7
+ """
8
+
9
+ from .client import CnetClient
10
+ from .config import CnetConfig
11
+ from .errors import (
12
+ ChecksumError,
13
+ CnetError,
14
+ CnetNakError,
15
+ CnetTimeoutError,
16
+ MalformedFrameError,
17
+ MalformedPayloadError,
18
+ UnexpectedResponseError,
19
+ )
20
+ from .serial_transport import SerialTransport
21
+ from .socket_transport import SocketTransport
22
+ from .transport import FakeTransport, Transport
23
+
24
+ __all__ = [
25
+ "ChecksumError",
26
+ "CnetClient",
27
+ "CnetConfig",
28
+ "CnetError",
29
+ "CnetNakError",
30
+ "CnetTimeoutError",
31
+ "FakeTransport",
32
+ "MalformedFrameError",
33
+ "MalformedPayloadError",
34
+ "SerialTransport",
35
+ "SocketTransport",
36
+ "Transport",
37
+ "UnexpectedResponseError",
38
+ ]
@@ -0,0 +1,203 @@
1
+ """Device address parsing and validation.
2
+
3
+ Example:
4
+ >>> from masterk_cnet.address import parse_address
5
+ >>> parse_address("%MW0001").word_index
6
+ 1
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ from .models import DataType, DeviceAddress, DeviceArea
14
+
15
+ ADDRESS_RE = re.compile(r"^%([A-Za-z])([A-Za-z])([0-9A-Fa-f]+)$")
16
+ MIN_DEVICE_DIGITS = 2
17
+ MAX_DEVICE_DIGITS = 8
18
+
19
+ WORD_RANGES: dict[DeviceArea, tuple[int, int]] = {
20
+ DeviceArea.P: (0, 31),
21
+ DeviceArea.M: (0, 191),
22
+ DeviceArea.K: (0, 31),
23
+ DeviceArea.L: (0, 63),
24
+ DeviceArea.F: (0, 63),
25
+ DeviceArea.S: (0, 99),
26
+ DeviceArea.D: (0, 4999),
27
+ DeviceArea.T: (0, 255),
28
+ DeviceArea.C: (0, 255),
29
+ }
30
+
31
+ BIT_WORD_RANGES: dict[DeviceArea, tuple[int, int]] = {
32
+ DeviceArea.P: (0, 31),
33
+ DeviceArea.M: (0, 191),
34
+ DeviceArea.K: (0, 31),
35
+ DeviceArea.L: (0, 63),
36
+ DeviceArea.F: (0, 63),
37
+ }
38
+
39
+ BIT_POINT_RANGES: dict[DeviceArea, tuple[int, int]] = {
40
+ DeviceArea.T: (0, 255),
41
+ DeviceArea.C: (0, 255),
42
+ }
43
+
44
+
45
+ def parse_address(address: str) -> DeviceAddress:
46
+ """Parse a CNET device address.
47
+
48
+ Args:
49
+ address: Device address text such as ``"%MW0001"`` or ``"%MX001F"``.
50
+
51
+ Returns:
52
+ Parsed device address.
53
+
54
+ Raises:
55
+ ValueError: If the address format, area, type, or range is invalid.
56
+
57
+ Example:
58
+ >>> parse_address("%MW0001").to_protocol_string()
59
+ '%MW0001'
60
+ """
61
+ match = ADDRESS_RE.match(address.strip())
62
+ if not match:
63
+ raise ValueError(f"invalid device address format: {address!r}")
64
+
65
+ area_text, type_text, digits = match.groups()
66
+ try:
67
+ # Operators often type area/type letters in mixed case; normalize those
68
+ # structural fields while leaving the device digits available for
69
+ # stricter checks below.
70
+ area = DeviceArea(area_text.upper())
71
+ data_type = DataType(type_text.upper())
72
+ except ValueError as exc:
73
+ raise ValueError(f"unsupported device address: {address!r}") from exc
74
+
75
+ if data_type is DataType.WORD:
76
+ return _parse_word_address(address, area, digits)
77
+ return _parse_bit_address(address, area, digits)
78
+
79
+
80
+ def ensure_readable(address: DeviceAddress) -> None:
81
+ """Validate that an address can be read.
82
+
83
+ Args:
84
+ address: Parsed device address to validate.
85
+
86
+ Raises:
87
+ ValueError: If the address is not readable.
88
+
89
+ Example:
90
+ >>> ensure_readable(parse_address("%MW0001"))
91
+ """
92
+ # Every supported area in this CNET subset is readable.
93
+ _ = address
94
+
95
+
96
+ def ensure_writable(address: DeviceAddress) -> None:
97
+ """Validate that an address can be written.
98
+
99
+ Args:
100
+ address: Parsed device address to validate.
101
+
102
+ Raises:
103
+ ValueError: If the address belongs to a read-only area.
104
+
105
+ Example:
106
+ >>> ensure_writable(parse_address("%MW0001"))
107
+ """
108
+ # The F area exposes flags/status and is treated as read-only by the PLC.
109
+ # Reject it before frame construction so callers get a local validation
110
+ # error instead of a device-side NAK.
111
+ if address.area is DeviceArea.F:
112
+ raise ValueError("F area is read-only")
113
+
114
+
115
+ def ensure_word(address: DeviceAddress) -> None:
116
+ """Validate that an address uses Word data.
117
+
118
+ Args:
119
+ address: Parsed device address to validate.
120
+
121
+ Raises:
122
+ ValueError: If the address is not a Word address.
123
+
124
+ Example:
125
+ >>> ensure_word(parse_address("%MW0001"))
126
+ """
127
+ if address.data_type is not DataType.WORD:
128
+ raise ValueError("operation requires a Word address")
129
+
130
+
131
+ def ensure_same_data_type(addresses: list[DeviceAddress]) -> None:
132
+ """Validate that all addresses use the same data type.
133
+
134
+ Args:
135
+ addresses: Parsed device addresses to compare.
136
+
137
+ Raises:
138
+ ValueError: If the list is empty or mixes Bit and Word addresses.
139
+
140
+ Example:
141
+ >>> ensure_same_data_type([parse_address("%MW0001"), parse_address("%MW0002")])
142
+ """
143
+ if not addresses:
144
+ raise ValueError("at least one address is required")
145
+ first_type = addresses[0].data_type
146
+ if any(address.data_type is not first_type for address in addresses):
147
+ raise ValueError("all addresses in one block command must use the same data type")
148
+
149
+
150
+ def _parse_word_address(raw: str, area: DeviceArea, digits: str) -> DeviceAddress:
151
+ if area not in WORD_RANGES:
152
+ raise ValueError(f"{area.value} area does not support Word access")
153
+ _check_digit_count(digits, raw)
154
+ if not digits.isdecimal():
155
+ raise ValueError("Word address digits must be decimal")
156
+
157
+ word_index = int(digits, 10)
158
+ _check_range(word_index, WORD_RANGES[area], raw)
159
+ return DeviceAddress(area, DataType.WORD, word_index, None, digits, raw)
160
+
161
+
162
+ def _parse_bit_address(raw: str, area: DeviceArea, digits: str) -> DeviceAddress:
163
+ _check_digit_count(digits, raw)
164
+ if area in BIT_POINT_RANGES:
165
+ # Timer and counter bit addresses are point-based in this protocol
166
+ # subset, not word-plus-nibble like P/M/K/L/F bit addresses.
167
+ if not digits.isdecimal():
168
+ raise ValueError("T/C bit address digits must be decimal")
169
+ point_index = int(digits, 10)
170
+ _check_range(point_index, BIT_POINT_RANGES[area], raw)
171
+ return DeviceAddress(area, DataType.BIT, point_index, None, digits, raw)
172
+
173
+ if area not in BIT_WORD_RANGES:
174
+ raise ValueError(f"{area.value} area does not support Bit access")
175
+ if len(digits) < 2:
176
+ raise ValueError("Bit address must include word digits and a bit nibble")
177
+
178
+ word_digits = digits[:-1]
179
+ bit_digit = digits[-1]
180
+ if not word_digits.isdecimal():
181
+ raise ValueError("Bit address word digits must be decimal")
182
+ # Hex bit nibbles are kept uppercase because the Master-K notation examples
183
+ # use uppercase A-F, and silently normalizing a lowercase nibble could hide
184
+ # a user input mistake in addresses that are later echoed in diagnostics.
185
+ if bit_digit.isalpha() and not bit_digit.isupper():
186
+ raise ValueError("Bit address nibble must be uppercase")
187
+ bit_index = int(bit_digit, 16)
188
+ word_index = int(word_digits, 10)
189
+ _check_range(word_index, BIT_WORD_RANGES[area], raw)
190
+ return DeviceAddress(area, DataType.BIT, word_index, bit_index, digits.upper(), raw)
191
+
192
+
193
+ def _check_range(value: int, allowed: tuple[int, int], raw: str) -> None:
194
+ start, end = allowed
195
+ if not start <= value <= end:
196
+ raise ValueError(f"address out of range for {raw!r}: {value}")
197
+
198
+
199
+ def _check_digit_count(digits: str, raw: str) -> None:
200
+ if not MIN_DEVICE_DIGITS <= len(digits) <= MAX_DEVICE_DIGITS:
201
+ raise ValueError(
202
+ f"device number must be {MIN_DEVICE_DIGITS} to {MAX_DEVICE_DIGITS} digits: {raw!r}"
203
+ )
@@ -0,0 +1,185 @@
1
+ """ASCII Hex conversion helpers for CNET frames.
2
+
3
+ Example:
4
+ >>> from masterk_cnet.ascii_hex import encode_word
5
+ >>> encode_word(4660)
6
+ b'1234'
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ HEX_CHARS = set("0123456789ABCDEFabcdef")
12
+
13
+
14
+ def int_to_ascii_hex(value: int, width: int) -> bytes:
15
+ """Encode an integer as zero-padded uppercase ASCII hex.
16
+
17
+ Args:
18
+ value: Non-negative integer to encode.
19
+ width: Number of ASCII hex digits to produce.
20
+
21
+ Returns:
22
+ Uppercase ASCII hex bytes.
23
+
24
+ Raises:
25
+ ValueError: If width is not positive, value is negative, or value does
26
+ not fit in the requested width.
27
+
28
+ Example:
29
+ >>> int_to_ascii_hex(10, 2)
30
+ b'0A'
31
+ """
32
+ if width <= 0:
33
+ raise ValueError("width must be positive")
34
+ if value < 0:
35
+ raise ValueError("value must be non-negative")
36
+ max_value = (16**width) - 1
37
+ if value > max_value:
38
+ raise ValueError(f"value {value} does not fit in {width} hex digits")
39
+ return f"{value:0{width}X}".encode("ascii")
40
+
41
+
42
+ def ascii_hex_to_int(data: bytes | str) -> int:
43
+ """Decode ASCII hex bytes or text to an integer.
44
+
45
+ Args:
46
+ data: ASCII hex bytes or text.
47
+
48
+ Returns:
49
+ Decoded integer value.
50
+
51
+ Raises:
52
+ ValueError: If data is empty or contains non-hex characters.
53
+
54
+ Example:
55
+ >>> ascii_hex_to_int(b'0A')
56
+ 10
57
+ """
58
+ text = data.decode("ascii") if isinstance(data, bytes) else data
59
+ if not text:
60
+ raise ValueError("hex data is empty")
61
+ if any(char not in HEX_CHARS for char in text):
62
+ raise ValueError(f"invalid hex data: {text!r}")
63
+ return int(text, 16)
64
+
65
+
66
+ def encode_byte(value: int) -> bytes:
67
+ """Encode an integer as a one-byte ASCII hex field.
68
+
69
+ Args:
70
+ value: Integer in the byte range.
71
+
72
+ Returns:
73
+ Two uppercase ASCII hex bytes.
74
+
75
+ Raises:
76
+ ValueError: If value cannot fit in one byte.
77
+
78
+ Example:
79
+ >>> encode_byte(255)
80
+ b'FF'
81
+ """
82
+ return int_to_ascii_hex(value, 2)
83
+
84
+
85
+ def encode_word(value: int) -> bytes:
86
+ """Encode an integer as a one-word ASCII hex field.
87
+
88
+ Args:
89
+ value: Integer in the word range.
90
+
91
+ Returns:
92
+ Four uppercase ASCII hex bytes.
93
+
94
+ Raises:
95
+ ValueError: If value cannot fit in one word.
96
+
97
+ Example:
98
+ >>> encode_word(4660)
99
+ b'1234'
100
+ """
101
+ return int_to_ascii_hex(value, 4)
102
+
103
+
104
+ def decode_byte(data: bytes | str) -> int:
105
+ """Decode a one-byte ASCII hex field.
106
+
107
+ Args:
108
+ data: Two ASCII hex characters as bytes or text.
109
+
110
+ Returns:
111
+ Decoded byte value.
112
+
113
+ Raises:
114
+ ValueError: If data is not exactly two ASCII hex characters.
115
+
116
+ Example:
117
+ >>> decode_byte('FF')
118
+ 255
119
+ """
120
+ text = data.decode("ascii") if isinstance(data, bytes) else data
121
+ if len(text) != 2:
122
+ raise ValueError("byte data must be 2 ASCII hex characters")
123
+ return ascii_hex_to_int(text)
124
+
125
+
126
+ def decode_word(data: bytes | str) -> int:
127
+ """Decode a one-word ASCII hex field.
128
+
129
+ Args:
130
+ data: Four ASCII hex characters as bytes or text.
131
+
132
+ Returns:
133
+ Decoded word value.
134
+
135
+ Raises:
136
+ ValueError: If data is not exactly four ASCII hex characters.
137
+
138
+ Example:
139
+ >>> decode_word('1234')
140
+ 4660
141
+ """
142
+ text = data.decode("ascii") if isinstance(data, bytes) else data
143
+ if len(text) != 4:
144
+ raise ValueError("word data must be 4 ASCII hex characters")
145
+ return ascii_hex_to_int(text)
146
+
147
+
148
+ def decode_ascii_hex_bytes(data: bytes | str) -> bytes:
149
+ """Decode ASCII hex text into raw bytes.
150
+
151
+ Args:
152
+ data: Even-length ASCII hex bytes or text.
153
+
154
+ Returns:
155
+ Raw bytes represented by the input.
156
+
157
+ Raises:
158
+ ValueError: If data has an odd length or contains non-hex characters.
159
+
160
+ Example:
161
+ >>> decode_ascii_hex_bytes('4142')
162
+ b'AB'
163
+ """
164
+ text = data.decode("ascii") if isinstance(data, bytes) else data
165
+ if len(text) % 2 != 0:
166
+ raise ValueError("hex byte data must have an even length")
167
+ if any(char not in HEX_CHARS for char in text):
168
+ raise ValueError(f"invalid hex data: {text!r}")
169
+ return bytes.fromhex(text)
170
+
171
+
172
+ def spaced_hex(data: bytes) -> str:
173
+ """Format bytes as uppercase hex pairs separated by spaces.
174
+
175
+ Args:
176
+ data: Raw bytes to format.
177
+
178
+ Returns:
179
+ Space-separated uppercase hex text.
180
+
181
+ Example:
182
+ >>> spaced_hex(b'\\x05\\x06')
183
+ '05 06'
184
+ """
185
+ return " ".join(f"{byte:02X}" for byte in data)
masterk_cnet/bcc.py ADDED
@@ -0,0 +1,44 @@
1
+ """BCC calculation for CNET frames.
2
+
3
+ Example:
4
+ >>> from masterk_cnet.bcc import calculate_bcc
5
+ >>> calculate_bcc(b'ABC')
6
+ b'C6'
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .ascii_hex import encode_byte
12
+
13
+
14
+ def calculate_bcc(data: bytes) -> bytes:
15
+ """Calculate the CNET BCC for a byte range.
16
+
17
+ Args:
18
+ data: Bytes included in the checksum calculation.
19
+
20
+ Returns:
21
+ Two uppercase ASCII hex bytes containing the low byte of the sum.
22
+
23
+ Example:
24
+ >>> calculate_bcc(b'ABC')
25
+ b'C6'
26
+ """
27
+ return encode_byte(sum(data) & 0xFF)
28
+
29
+
30
+ def verify_bcc(data: bytes, expected: bytes) -> bool:
31
+ """Compare a calculated CNET BCC with an expected value.
32
+
33
+ Args:
34
+ data: Bytes included in the checksum calculation.
35
+ expected: Expected ASCII hex BCC bytes.
36
+
37
+ Returns:
38
+ True when the calculated checksum matches the expected value.
39
+
40
+ Example:
41
+ >>> verify_bcc(b'ABC', b'C6')
42
+ True
43
+ """
44
+ return calculate_bcc(data).upper() == expected.upper()