scietex.hal.vfd 2.0.0__tar.gz → 2.0.2__tar.gz

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 (44) hide show
  1. {scietex_hal_vfd-2.0.0/src/scietex.hal.vfd.egg-info → scietex_hal_vfd-2.0.2}/PKG-INFO +8 -2
  2. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/README.md +7 -1
  3. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/data.py +5 -2
  4. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/rs485.py +24 -0
  5. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/client.py +67 -18
  6. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/rs485.py +15 -5
  7. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/version.py +1 -1
  8. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/client.py +43 -12
  9. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/rs485.py +11 -5
  10. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/client.py +35 -6
  11. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/rs485.py +6 -2
  12. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2/src/scietex.hal.vfd.egg-info}/PKG-INFO +8 -2
  13. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/SOURCES.txt +2 -1
  14. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_modbus_client.py +67 -3
  15. scietex_hal_vfd-2.0.2/tests/test_vesper_optional_reads.py +176 -0
  16. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/LICENSE +0 -0
  17. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/pyproject.toml +0 -0
  18. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/setup.cfg +0 -0
  19. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/__init__.py +0 -0
  20. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/__init__.py +0 -0
  21. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/capabilities.py +0 -0
  22. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/base/protocol.py +0 -0
  23. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/__init__.py +0 -0
  24. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/__init__.py +0 -0
  25. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/errors.py +0 -0
  26. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/intek/spe_b/registers.py +0 -0
  27. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/modbus_drivers.py +0 -0
  28. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/py.typed +0 -0
  29. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/__init__.py +0 -0
  30. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/__init__.py +0 -0
  31. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/errors.py +0 -0
  32. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e4_8400/registers.py +0 -0
  33. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/__init__.py +0 -0
  34. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/errors.py +0 -0
  35. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex/hal/vfd/vesper/e5_8200/registers.py +0 -0
  36. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/dependency_links.txt +0 -0
  37. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/entry_points.txt +0 -0
  38. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/requires.txt +0 -0
  39. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/src/scietex.hal.vfd.egg-info/top_level.txt +0 -0
  40. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_modbus_drivers.py +0 -0
  41. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_registers.py +0 -0
  42. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_version.py +0 -0
  43. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_vesper_e4_8400_registers.py +0 -0
  44. {scietex_hal_vfd-2.0.0 → scietex_hal_vfd-2.0.2}/tests/test_vesper_e5_8200_registers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scietex.hal.vfd
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Scietex VFD HAL package
5
5
  Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
6
  License: MIT License
@@ -86,7 +86,7 @@ A driver reaches a drive over one of two transports:
86
86
 
87
87
  | Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
88
88
  | --- | --- | --- | --- |
89
- | `frequency_hz` | yes | yes | yes |
89
+ | `frequency_hz` | yes¹ | yes | yes |
90
90
  | `frequency_percent` | yes | yes | yes |
91
91
  | `output_current` | yes | yes | yes |
92
92
  | `output_voltage` | yes | yes | yes |
@@ -98,6 +98,12 @@ A driver reaches a drive over one of two transports:
98
98
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
99
99
  two report overheat only as a fault code, and a fault is not a temperature.
100
100
 
101
+ ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
102
+ `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
103
+ `P0.10` still reports `frequency_percent`, current, voltage and power; the
104
+ connection stays usable and only the Hz field degrades. `set_frequency` is
105
+ refused in that state, because no percentage can be computed.
106
+
101
107
  ## Installation
102
108
 
103
109
  Install the package via pip (assuming it's published to PyPI):
@@ -38,7 +38,7 @@ A driver reaches a drive over one of two transports:
38
38
 
39
39
  | Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
40
40
  | --- | --- | --- | --- |
41
- | `frequency_hz` | yes | yes | yes |
41
+ | `frequency_hz` | yes¹ | yes | yes |
42
42
  | `frequency_percent` | yes | yes | yes |
43
43
  | `output_current` | yes | yes | yes |
44
44
  | `output_voltage` | yes | yes | yes |
@@ -50,6 +50,12 @@ A driver reaches a drive over one of two transports:
50
50
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
51
51
  two report overheat only as a fault code, and a fault is not a temperature.
52
52
 
53
+ ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
54
+ `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
55
+ `P0.10` still reports `frequency_percent`, current, voltage and power; the
56
+ connection stays usable and only the Hz field degrades. `set_frequency` is
57
+ refused in that state, because no percentage can be computed.
58
+
53
59
  ## Installation
54
60
 
55
61
  Install the package via pip (assuming it's published to PyPI):
@@ -44,7 +44,10 @@ class VFDParameters(msgspec.Struct, frozen=True):
44
44
  """Model for VFD operational parameters.
45
45
 
46
46
  Attributes:
47
- frequency_hz: Output frequency in Hz.
47
+ frequency_hz: Output frequency in Hz, or ``None`` when the drive
48
+ reports the frequency only as a percentage and the maximum it is a
49
+ percentage of is unknown. ``None`` rather than 0.0 because the drive
50
+ has not reported a frequency of zero — it has reported nothing.
48
51
  frequency_percent: Output frequency as a percentage of the configured
49
52
  maximum, 0-100.
50
53
  output_current: Output current in Amps.
@@ -61,7 +64,7 @@ class VFDParameters(msgspec.Struct, frozen=True):
61
64
  state: Current VFD state.
62
65
  """
63
66
 
64
- frequency_hz: float # Current operating frequency in Hz
67
+ frequency_hz: float | None # Current operating frequency in Hz
65
68
  frequency_percent: float # Frequency as a percentage of max (0-100%)
66
69
  output_current: float = 0.0 # Output current in Amps
67
70
  output_voltage: float = 0.0 # Output voltage in Volts
@@ -4,6 +4,7 @@ from logging import Logger
4
4
  from typing import Any
5
5
 
6
6
  from scietex.hal.serial import (
7
+ ModbusOperationError,
7
8
  ModbusSerialConnectionConfig,
8
9
  RS485Client,
9
10
  SerialConnectionConfig,
@@ -116,6 +117,29 @@ class VFD(RS485Client, VFDProtocol):
116
117
  parameters = await self.read_parameters()
117
118
  return {f: getattr(parameters, f) for f in parameters.__struct_fields__}
118
119
 
120
+ async def _read_optional_register(self, register: int) -> int | None:
121
+ """
122
+ Read one register, returning ``None`` instead of raising on failure.
123
+
124
+ Used for readings that refine a snapshot but must not fail it: a drive
125
+ that refuses one diagnostic register still reports its frequency,
126
+ current and state, and a poll loop that raised on every optional read
127
+ would never publish anything.
128
+
129
+ Args:
130
+ register: The holding register address.
131
+
132
+ Returns:
133
+ int | None: The register value, or ``None`` when the read failed.
134
+ """
135
+ try:
136
+ return await self.read_register(register, holding=True)
137
+ except ModbusOperationError as exc:
138
+ self.logger.warning(
139
+ "%s: Optional register %s is unreadable (%s).", self.label, register, exc
140
+ )
141
+ return None
142
+
119
143
  async def process_message(self, message: dict[str, Any]) -> None:
120
144
  await super().process_message(message)
121
145
  if "cmd" in message:
@@ -103,11 +103,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
103
103
  Open the TCP connection to the gateway and read the drive's scale factors.
104
104
 
105
105
  Raises:
106
- ModbusOperationError: If the gateway is unreachable, or if P0.10
107
- cannot be read. pymodbus reports a failed connect by returning
108
- ``False`` rather than raising, so the result is checked here;
109
- otherwise an unreachable drive would look connected and only
110
- fail on the first read.
106
+ ModbusOperationError: If the gateway is unreachable. pymodbus
107
+ reports a failed connect by returning ``False`` rather than
108
+ raising, so the result is checked here; otherwise an
109
+ unreachable drive would look connected and only fail on the
110
+ first read. An unreadable P0.10 is not an error: the drive
111
+ still reports its measurements, only ``frequency_hz`` degrades
112
+ to ``None``.
111
113
  """
112
114
  if self._client is None:
113
115
  self._client = AsyncModbusTcpClient(self._host, port=self._port, timeout=self._timeout)
@@ -157,6 +159,31 @@ class IntekSPEBModbusVFD(VFDProtocol):
157
159
  )
158
160
  return list(response.registers)
159
161
 
162
+ async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
163
+ """
164
+ Read holding registers, returning ``None`` instead of raising on failure.
165
+
166
+ Used for readings that refine a snapshot but must not fail it: a drive
167
+ that refuses one diagnostic register still reports its frequency,
168
+ current and state, and a poll loop that raised on every optional read
169
+ would never publish anything.
170
+
171
+ Args:
172
+ address: Starting register address.
173
+ count: Number of registers to read.
174
+
175
+ Returns:
176
+ list[int] | None: The register values, or ``None`` when the read
177
+ failed.
178
+ """
179
+ try:
180
+ return await self._read_registers(address, count)
181
+ except ModbusOperationError as exc:
182
+ self.logger.warning(
183
+ "%s: Optional register %s is unreadable (%s).", self.label, address, exc
184
+ )
185
+ return None
186
+
160
187
  async def _write_register(self, address: int, value: int) -> None:
161
188
  """
162
189
  Write a single holding register, raising on a Modbus exception response.
@@ -180,16 +207,25 @@ class IntekSPEBModbusVFD(VFDProtocol):
180
207
  """
181
208
  Read and cache the P0.10 maximum frequency and the Pd.06 resolution.
182
209
 
183
- Raises:
184
- ModbusOperationError: If P0.10 cannot be read, because every
185
- frequency this client reports would then be a guess.
210
+ P0.10 is optional: a drive that refuses it still reports its operating
211
+ percentage, current, voltage and power, so the connection stays usable
212
+ and only ``frequency_hz`` degrades to ``None``. Raising here would make
213
+ the poll loop log a connect failure every cycle and publish nothing at
214
+ all, which is what an SPE-B that has not been configured for Modbus
215
+ (Pd.01/Pd.05) does — it answers the gateway with exception 11.
186
216
  """
187
- raw = await self._read_registers(registers.PARAM_MAX_FREQUENCY_EEPROM, 1)
188
- self._max_frequency_hz = raw[0] / registers.PARAMETER_FREQUENCY_RESOLUTION
189
- if self._max_frequency_hz <= 0:
217
+ try:
218
+ raw = await self._read_registers(registers.PARAM_MAX_FREQUENCY_EEPROM, 1)
219
+ except ModbusOperationError as exc:
190
220
  self.logger.warning(
191
- "%s: P0.10 reads %d, frequency is reported as 0 Hz.", self.label, raw[0]
221
+ "%s: P0.10 is unreadable (%s), frequency is reported as None.", self.label, exc
192
222
  )
223
+ else:
224
+ self._max_frequency_hz = raw[0] / registers.PARAMETER_FREQUENCY_RESOLUTION
225
+ if self._max_frequency_hz <= 0:
226
+ self.logger.warning(
227
+ "%s: P0.10 reads %d, frequency is reported as None.", self.label, raw[0]
228
+ )
193
229
  # Pd.06 only refines the current reading, so a drive that refuses the
194
230
  # read keeps working with the factory resolution instead of failing
195
231
  # the connection: stop and fault reset must stay available.
@@ -204,7 +240,7 @@ class IntekSPEBModbusVFD(VFDProtocol):
204
240
  else registers.CURRENT_RESOLUTION_FINE
205
241
  )
206
242
 
207
- def _percent_to_hz(self, percent: float) -> float:
243
+ def _percent_to_hz(self, percent: float) -> float | None:
208
244
  """
209
245
  Convert an operating frequency percentage into Hz.
210
246
 
@@ -212,10 +248,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
212
248
  percent: Frequency as reported by the drive.
213
249
 
214
250
  Returns:
215
- float: The frequency in Hz, or 0.0 while P0.10 is unknown.
251
+ float | None: The frequency in Hz, or ``None`` while P0.10 is
252
+ unknown. ``None`` rather than 0.0 because a drive that refuses
253
+ P0.10 has not reported a frequency of zero — it has reported
254
+ nothing, and a caller must be able to tell the two apart.
216
255
  """
217
256
  if self._max_frequency_hz <= 0:
218
- return 0.0
257
+ return None
219
258
  return registers.percent_to_hz(percent, self._max_frequency_hz)
220
259
 
221
260
  async def read_parameters(self) -> VFDParameters:
@@ -227,9 +266,16 @@ class IntekSPEBModbusVFD(VFDProtocol):
227
266
  drive sends them, because the manual states no scaling factor for
228
267
  either; current is scaled by the Pd.06 resolution.
229
268
 
269
+ The run state is read separately and is optional: a drive that refuses
270
+ the status register still reports its measurements, so the state falls
271
+ back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
272
+
230
273
  Returns:
231
274
  VFDParameters: The decoded parameters. Both temperature fields are
232
275
  ``None``: SPE-B reports overheat as a fault code, not a reading.
276
+
277
+ Raises:
278
+ ModbusOperationError: If the measurement block cannot be read.
233
279
  """
234
280
  block = await self._read_registers(
235
281
  registers.PARAMETERS_BLOCK_ADDRESS, registers.PARAMETERS_BLOCK_COUNT
@@ -264,10 +310,13 @@ class IntekSPEBModbusVFD(VFDProtocol):
264
310
  Read the drive's run state.
265
311
 
266
312
  Returns:
267
- VFDState: The decoded state, or ``VFDState.UNKNOWN`` for a status
268
- value the manual does not define.
313
+ VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the status
314
+ register is unreadable or holds a value the manual does not
315
+ define.
269
316
  """
270
- raw = await self._read_registers(registers.REG_STATUS, 1)
317
+ raw = await self._read_optional_registers(registers.REG_STATUS, 1)
318
+ if raw is None:
319
+ return VFDState.UNKNOWN
271
320
  state = registers.status_to_state(raw[0])
272
321
  self.logger.debug("%s: Current state: %s", self.label, state)
273
322
  return state
@@ -53,12 +53,22 @@ class SPEvB(VFD):
53
53
  self._current_resolution = registers.CURRENT_RESOLUTION_FINE
54
54
 
55
55
  async def _read_scale_factors(self) -> None:
56
- """Read P0.10 and Pd.06 from the drive once and cache them."""
57
- raw = await self.read_register(registers.PARAM_MAX_FREQUENCY_EEPROM, holding=True)
56
+ """
57
+ Read P0.10 and Pd.06 from the drive once and cache them.
58
+
59
+ P0.10 is optional: a drive that refuses it still reports its operating
60
+ percentage, current, voltage and power, so only ``frequency_hz``
61
+ degrades to ``None``. Raising here would fail the whole snapshot.
62
+ """
63
+ raw = await self.read_register(
64
+ registers.PARAM_MAX_FREQUENCY_EEPROM,
65
+ holding=True,
66
+ raise_on_error=False,
67
+ )
58
68
  self._max_frequency_hz = (raw or 0) / registers.PARAMETER_FREQUENCY_RESOLUTION
59
69
  if self._max_frequency_hz <= 0:
60
70
  self.logger.warning(
61
- "%s: P0.10 reads %s, frequency is reported as 0 Hz.", self.label, raw
71
+ "%s: P0.10 reads %s, frequency is reported as None.", self.label, raw
62
72
  )
63
73
  # Pd.06 only refines the current reading, so a drive that refuses the
64
74
  # read keeps working with the factory resolution.
@@ -114,7 +124,7 @@ class SPEvB(VFD):
114
124
  frequency_hz = (
115
125
  registers.percent_to_hz(percent, self._max_frequency_hz)
116
126
  if self._max_frequency_hz
117
- else 0.0
127
+ else None
118
128
  )
119
129
  state = await self.read_state()
120
130
  return VFDParameters(
@@ -135,7 +145,7 @@ class SPEvB(VFD):
135
145
 
136
146
  async def read_state(self) -> VFDState:
137
147
  """Read the VFD run state"""
138
- status = await self.read_register(registers.REG_STATUS, holding=True)
148
+ status = await self._read_optional_register(registers.REG_STATUS)
139
149
  if status is None:
140
150
  return VFDState.UNKNOWN
141
151
  return registers.status_to_state(status)
@@ -5,4 +5,4 @@ Attributes:
5
5
  __version__ (str): The current version of the package.
6
6
  """
7
7
 
8
- __version__ = "2.0.0"
8
+ __version__ = "2.0.2"
@@ -160,6 +160,31 @@ class VesperE48400ModbusVFD(VFDProtocol):
160
160
  )
161
161
  return list(response.registers)
162
162
 
163
+ async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
164
+ """
165
+ Read holding registers, returning ``None`` instead of raising on failure.
166
+
167
+ Used for readings that refine a snapshot but must not fail it: a drive
168
+ that refuses one diagnostic register still reports its frequency,
169
+ current and state, and a poll loop that raised on every optional read
170
+ would never publish anything.
171
+
172
+ Args:
173
+ address: Starting register address.
174
+ count: Number of registers to read.
175
+
176
+ Returns:
177
+ list[int] | None: The register values, or ``None`` when the read
178
+ failed.
179
+ """
180
+ try:
181
+ return await self._read_registers(address, count)
182
+ except ModbusOperationError as exc:
183
+ self.logger.warning(
184
+ "%s: Optional register %s is unreadable (%s).", self.label, address, exc
185
+ )
186
+ return None
187
+
163
188
  async def _write_register(self, address: int, value: int) -> None:
164
189
  """
165
190
  Write a single holding register, raising on a Modbus exception response.
@@ -221,13 +246,19 @@ class VesperE48400ModbusVFD(VFDProtocol):
221
246
  temperature are read separately because the gaps between them would
222
247
  otherwise pull in reserved registers.
223
248
 
249
+ The three separate readings and the run state are optional: a drive that
250
+ refuses one of them still reports its frequency and current, so the
251
+ affected field stays ``None`` (or ``VFDState.UNKNOWN``) instead of
252
+ failing the whole snapshot.
253
+
224
254
  Returns:
225
255
  VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
226
256
  populated from U1-46; ``motor_temperature_c`` stays ``None``
227
257
  because the drive reports motor overheat only as a fault.
228
258
 
229
259
  Raises:
230
- ModbusOperationError: If any read fails.
260
+ ModbusOperationError: If the measurement block or E1-04 cannot be
261
+ read.
231
262
  """
232
263
  await self._read_max_frequency()
233
264
  block = await self._read_registers(
@@ -235,16 +266,16 @@ class VesperE48400ModbusVFD(VFDProtocol):
235
266
  )
236
267
  offset = registers.PARAMETERS_BLOCK_ADDRESS
237
268
  frequency_hz = registers.output_frequency_hz(block[registers.REG_OUTPUT_FREQUENCY - offset])
238
- voltage = await self._read_registers(registers.REG_OUTPUT_VOLTAGE, 1)
239
- dc_bus = await self._read_registers(registers.REG_DC_BUS_VOLTAGE, 1)
240
- temperature = await self._read_registers(registers.REG_HEATSINK_TEMPERATURE, 1)
269
+ voltage = await self._read_optional_registers(registers.REG_OUTPUT_VOLTAGE, 1)
270
+ dc_bus = await self._read_optional_registers(registers.REG_DC_BUS_VOLTAGE, 1)
271
+ temperature = await self._read_optional_registers(registers.REG_HEATSINK_TEMPERATURE, 1)
241
272
  parameters = VFDParameters(
242
273
  frequency_hz=frequency_hz,
243
274
  frequency_percent=self._percent(frequency_hz),
244
275
  output_current=registers.output_current_a(block[registers.REG_OUTPUT_CURRENT - offset]),
245
- output_voltage=float(voltage[0]),
246
- dc_bus_voltage=float(dc_bus[0]),
247
- heatsink_temperature_c=float(temperature[0]),
276
+ output_voltage=float(voltage[0]) if voltage is not None else 0.0,
277
+ dc_bus_voltage=float(dc_bus[0]) if dc_bus is not None else None,
278
+ heatsink_temperature_c=float(temperature[0]) if temperature is not None else None,
248
279
  state=await self.read_state(),
249
280
  )
250
281
  self.logger.debug("%s: %s", self.label, parameters)
@@ -255,12 +286,12 @@ class VesperE48400ModbusVFD(VFDProtocol):
255
286
  Read the drive's run state.
256
287
 
257
288
  Returns:
258
- VFDState: The decoded state.
259
-
260
- Raises:
261
- ModbusOperationError: If the status register cannot be read.
289
+ VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the status
290
+ register is unreadable.
262
291
  """
263
- raw = await self._read_registers(registers.REG_STATUS, 1)
292
+ raw = await self._read_optional_registers(registers.REG_STATUS, 1)
293
+ if raw is None:
294
+ return VFDState.UNKNOWN
264
295
  state = registers.status_to_state(raw[0])
265
296
  self.logger.debug("%s: Current state: %s", self.label, state)
266
297
  return state
@@ -198,13 +198,19 @@ class VesperE48400(VFD):
198
198
  temperature are read separately because the gaps between them would
199
199
  otherwise pull in reserved registers.
200
200
 
201
+ The three separate readings and the run state are optional: a drive that
202
+ refuses one of them still reports its frequency and current, so the
203
+ affected field stays ``None`` (or ``VFDState.UNKNOWN``) instead of
204
+ failing the whole snapshot.
205
+
201
206
  Returns:
202
207
  VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
203
208
  populated from U1-46; ``motor_temperature_c`` stays ``None``
204
209
  because the drive reports motor overheat only as a fault.
205
210
 
206
211
  Raises:
207
- ModbusOperationError: If any read fails.
212
+ ModbusOperationError: If the measurement block or E1-04 cannot be
213
+ read.
208
214
  """
209
215
  await self._read_max_frequency()
210
216
  block = await self.read_registers(
@@ -218,9 +224,9 @@ class VesperE48400(VFD):
218
224
  )
219
225
  offset = registers.PARAMETERS_BLOCK_ADDRESS
220
226
  frequency_hz = registers.output_frequency_hz(block[registers.REG_OUTPUT_FREQUENCY - offset])
221
- voltage = await self.read_register(registers.REG_OUTPUT_VOLTAGE, holding=True)
222
- dc_bus = await self.read_register(registers.REG_DC_BUS_VOLTAGE, holding=True)
223
- temperature = await self.read_register(registers.REG_HEATSINK_TEMPERATURE, holding=True)
227
+ voltage = await self._read_optional_register(registers.REG_OUTPUT_VOLTAGE)
228
+ dc_bus = await self._read_optional_register(registers.REG_DC_BUS_VOLTAGE)
229
+ temperature = await self._read_optional_register(registers.REG_HEATSINK_TEMPERATURE)
224
230
  return VFDParameters(
225
231
  frequency_hz=frequency_hz,
226
232
  frequency_percent=self._percent(frequency_hz),
@@ -239,7 +245,7 @@ class VesperE48400(VFD):
239
245
  VFDState: The decoded state, or ``VFDState.UNKNOWN`` if the status
240
246
  register cannot be read.
241
247
  """
242
- status = await self.read_register(registers.REG_STATUS, holding=True)
248
+ status = await self._read_optional_register(registers.REG_STATUS)
243
249
  if status is None:
244
250
  return VFDState.UNKNOWN
245
251
  return registers.status_to_state(status)
@@ -156,6 +156,31 @@ class VesperE58200ModbusVFD(VFDProtocol):
156
156
  )
157
157
  return list(response.registers)
158
158
 
159
+ async def _read_optional_registers(self, address: int, count: int) -> list[int] | None:
160
+ """
161
+ Read holding registers, returning ``None`` instead of raising on failure.
162
+
163
+ Used for readings that refine a snapshot but must not fail it: a drive
164
+ that refuses one diagnostic register still reports its frequency,
165
+ current and state, and a poll loop that raised on every optional read
166
+ would never publish anything.
167
+
168
+ Args:
169
+ address: Starting register address.
170
+ count: Number of registers to read.
171
+
172
+ Returns:
173
+ list[int] | None: The register values, or ``None`` when the read
174
+ failed.
175
+ """
176
+ try:
177
+ return await self._read_registers(address, count)
178
+ except ModbusOperationError as exc:
179
+ self.logger.warning(
180
+ "%s: Optional register %s is unreadable (%s).", self.label, address, exc
181
+ )
182
+ return None
183
+
159
184
  async def _write_register(self, address: int, value: int) -> None:
160
185
  """
161
186
  Write a single holding register, raising on a Modbus exception response.
@@ -198,11 +223,15 @@ class VesperE58200ModbusVFD(VFDProtocol):
198
223
  field stays 0.0, and no temperature, so both temperature fields stay
199
224
  ``None``.
200
225
 
226
+ The run state is read separately and is optional: a drive that refuses
227
+ the monitor register still reports its measurements, so the state falls
228
+ back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
229
+
201
230
  Returns:
202
231
  VFDParameters: The decoded parameters.
203
232
 
204
233
  Raises:
205
- ModbusOperationError: If the block cannot be read.
234
+ ModbusOperationError: If the measurement block cannot be read.
206
235
  """
207
236
  block = await self._read_registers(
208
237
  registers.PARAMETERS_BLOCK_ADDRESS, registers.PARAMETERS_BLOCK_COUNT
@@ -225,12 +254,12 @@ class VesperE58200ModbusVFD(VFDProtocol):
225
254
  Read the drive's run state.
226
255
 
227
256
  Returns:
228
- VFDState: The decoded state.
229
-
230
- Raises:
231
- ModbusOperationError: If the monitor register cannot be read.
257
+ VFDState: The decoded state, or ``VFDState.UNKNOWN`` when the monitor
258
+ register is unreadable.
232
259
  """
233
- raw = await self._read_registers(registers.REG_MONITOR, 1)
260
+ raw = await self._read_optional_registers(registers.REG_MONITOR, 1)
261
+ if raw is None:
262
+ return VFDState.UNKNOWN
234
263
  state = registers.status_to_state(raw[0])
235
264
  self.logger.debug("%s: Current state: %s", self.label, state)
236
265
  return state
@@ -133,11 +133,15 @@ class VesperE58200(VFD):
133
133
  field stays 0.0, and no temperature, so both temperature fields stay
134
134
  ``None``.
135
135
 
136
+ The run state is read separately and is optional: a drive that refuses
137
+ the monitor register still reports its measurements, so the state falls
138
+ back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
139
+
136
140
  Returns:
137
141
  VFDParameters: The decoded parameters.
138
142
 
139
143
  Raises:
140
- ModbusOperationError: If the block cannot be read.
144
+ ModbusOperationError: If the measurement block cannot be read.
141
145
  """
142
146
  block = await self.read_registers(
143
147
  registers.PARAMETERS_BLOCK_ADDRESS,
@@ -167,7 +171,7 @@ class VesperE58200(VFD):
167
171
  VFDState: The decoded state, or ``VFDState.UNKNOWN`` if the monitor
168
172
  register cannot be read.
169
173
  """
170
- monitor = await self.read_register(registers.REG_MONITOR, holding=True)
174
+ monitor = await self._read_optional_register(registers.REG_MONITOR)
171
175
  if monitor is None:
172
176
  return VFDState.UNKNOWN
173
177
  return registers.status_to_state(monitor)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scietex.hal.vfd
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Scietex VFD HAL package
5
5
  Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
6
  License: MIT License
@@ -86,7 +86,7 @@ A driver reaches a drive over one of two transports:
86
86
 
87
87
  | Field | Intek SPE-B | Vesper E5-8200 | Vesper E4-8400 |
88
88
  | --- | --- | --- | --- |
89
- | `frequency_hz` | yes | yes | yes |
89
+ | `frequency_hz` | yes¹ | yes | yes |
90
90
  | `frequency_percent` | yes | yes | yes |
91
91
  | `output_current` | yes | yes | yes |
92
92
  | `output_voltage` | yes | yes | yes |
@@ -98,6 +98,12 @@ A driver reaches a drive over one of two transports:
98
98
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
99
99
  two report overheat only as a fault code, and a fault is not a temperature.
100
100
 
101
+ ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
102
+ `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
103
+ `P0.10` still reports `frequency_percent`, current, voltage and power; the
104
+ connection stays usable and only the Hz field degrades. `set_frequency` is
105
+ refused in that state, because no percentage can be computed.
106
+
101
107
  ## Installation
102
108
 
103
109
  Install the package via pip (assuming it's published to PyPI):
@@ -38,4 +38,5 @@ tests/test_modbus_drivers.py
38
38
  tests/test_registers.py
39
39
  tests/test_version.py
40
40
  tests/test_vesper_e4_8400_registers.py
41
- tests/test_vesper_e5_8200_registers.py
41
+ tests/test_vesper_e5_8200_registers.py
42
+ tests/test_vesper_optional_reads.py
@@ -159,12 +159,76 @@ def test_read_parameters_uses_the_coarse_current_resolution():
159
159
  assert asyncio.run(vfd.read_parameters()).output_current == pytest.approx(12.3)
160
160
 
161
161
 
162
- def test_read_parameters_reports_zero_hz_when_p010_is_zero():
163
- """An unreadable maximum frequency keeps the percentage but cannot scale it."""
162
+ def test_read_parameters_reports_no_hz_when_p010_is_zero():
163
+ """An unusable maximum frequency keeps the percentage but cannot scale it."""
164
164
  vfd, _ = _connected([_read([5000, 0, 0, 0, 0]), _read([3])], max_frequency_raw=0)
165
165
  parameters = asyncio.run(vfd.read_parameters())
166
166
  assert parameters.frequency_percent == pytest.approx(50.0)
167
- assert parameters.frequency_hz == pytest.approx(0.0)
167
+ assert parameters.frequency_hz is None
168
+
169
+
170
+ def test_connect_survives_a_refused_p010_and_still_reports_measurements():
171
+ """A drive that refuses P0.10 must not blank the whole snapshot.
172
+
173
+ An SPE-B that has not been configured for Modbus (Pd.01/Pd.05) answers the
174
+ gateway with exception 11 for every register. Raising from `connect()` made
175
+ the poll loop log a connect failure every cycle and publish nothing; the
176
+ percentage, current, voltage and power are still readable, so only
177
+ `frequency_hz` degrades to None.
178
+ """
179
+ stub = _StubClient(
180
+ [
181
+ ExceptionResponse(function_code=3), # P0.10
182
+ _read([1]), # Pd.06 = coarse, 0.1 A
183
+ _read([5000, 540, 380, 123, 45]),
184
+ _read([1]), # status: running forward
185
+ ]
186
+ )
187
+ vfd = IntekSPEBModbusVFD("127.0.0.1")
188
+ vfd._client = stub
189
+ asyncio.run(vfd.connect())
190
+ parameters = asyncio.run(vfd.read_parameters())
191
+ assert parameters.frequency_percent == pytest.approx(50.0)
192
+ assert parameters.frequency_hz is None
193
+ assert parameters.output_current == pytest.approx(12.3)
194
+ assert parameters.output_voltage == pytest.approx(380.0)
195
+ assert parameters.dc_bus_voltage == pytest.approx(540.0)
196
+ assert parameters.state is VFDState.RUNNING_FORWARD
197
+
198
+
199
+ def test_set_frequency_still_refuses_when_p010_is_unknown():
200
+ """Without P0.10 no percentage can be computed, so the write is rejected."""
201
+ vfd, _ = _connected([], max_frequency_raw=0)
202
+ with pytest.raises(ValueError, match="P0.10"):
203
+ asyncio.run(vfd.set_frequency(25.0))
204
+
205
+
206
+ def test_read_parameters_survives_an_unreadable_status_register():
207
+ """A refused status read must not discard the measurements.
208
+
209
+ The poll loop publishes whatever `read_parameters` returns; raising here
210
+ would mean a drive that refuses one diagnostic register never reports its
211
+ frequency, current or voltage at all.
212
+ """
213
+ vfd, _ = _connected([_read([5000, 540, 380, 123, 45]), ExceptionResponse(function_code=3)])
214
+ parameters = asyncio.run(vfd.read_parameters())
215
+ assert parameters.frequency_hz == pytest.approx(25.0)
216
+ assert parameters.output_current == pytest.approx(1.23)
217
+ assert parameters.output_voltage == pytest.approx(380.0)
218
+ assert parameters.state is VFDState.UNKNOWN
219
+
220
+
221
+ def test_read_parameters_still_raises_when_the_measurement_block_fails():
222
+ """The measurement block is mandatory: without it there is nothing to report."""
223
+ vfd, _ = _connected([ExceptionResponse(function_code=3)])
224
+ with pytest.raises(ModbusOperationError):
225
+ asyncio.run(vfd.read_parameters())
226
+
227
+
228
+ def test_read_state_returns_unknown_when_the_status_register_is_refused():
229
+ """A refused status read is "unknown", not an exception."""
230
+ vfd, _ = _connected([ExceptionResponse(function_code=3)])
231
+ assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
168
232
 
169
233
 
170
234
  # -- state and faults -------------------------------------------------------
@@ -0,0 +1,176 @@
1
+ """
2
+ Tests for the optional readings in the Vesper gateway clients.
3
+
4
+ A poll loop publishes whatever `read_parameters` returns, so a drive that
5
+ refuses one diagnostic register must still report its frequency, current and
6
+ voltage. Only the measurement block is mandatory; the run state and the
7
+ separate diagnostic registers degrade to ``None`` / ``VFDState.UNKNOWN``.
8
+
9
+ The pymodbus client is replaced by a stub that returns canned responses, so
10
+ these cover the degradation rules rather than the transport.
11
+ """
12
+
13
+ import asyncio
14
+
15
+ import pytest
16
+ from pymodbus.pdu import ExceptionResponse
17
+ from pymodbus.pdu.register_message import ReadHoldingRegistersResponse
18
+ from scietex.hal.serial import ModbusOperationError
19
+
20
+ from scietex.hal.vfd.base.data import VFDState
21
+ from scietex.hal.vfd.vesper.e4_8400 import registers as e4_registers
22
+ from scietex.hal.vfd.vesper.e4_8400.client import VesperE48400ModbusVFD
23
+ from scietex.hal.vfd.vesper.e5_8200 import registers as e5_registers
24
+ from scietex.hal.vfd.vesper.e5_8200.client import VesperE58200ModbusVFD
25
+
26
+ #: E1-04 = 50.00 Hz, the factory maximum output frequency.
27
+ E4_MAX_FREQUENCY_RAW = 5000
28
+
29
+
30
+ class _StubClient:
31
+ """Stand-in for `AsyncModbusTcpClient` that replays canned responses."""
32
+
33
+ def __init__(self, responses):
34
+ self.responses = list(responses)
35
+ self.calls = []
36
+
37
+ async def read_holding_registers(self, address, *, count=1, device_id=1):
38
+ """Record the read and return the next canned response."""
39
+ self.calls.append(("read", address, count, device_id))
40
+ if not self.responses:
41
+ raise AssertionError("stub ran out of canned responses")
42
+ return self.responses.pop(0)
43
+
44
+
45
+ def _read(values):
46
+ return ReadHoldingRegistersResponse(registers=list(values))
47
+
48
+
49
+ def _refused():
50
+ return ExceptionResponse(function_code=3)
51
+
52
+
53
+ def _e5(responses):
54
+ """Build an E5-8200 client whose stub replays ``responses``."""
55
+ vfd = VesperE58200ModbusVFD("127.0.0.1")
56
+ vfd._client = _StubClient(responses)
57
+ return vfd
58
+
59
+
60
+ def _e4(responses):
61
+ """Build an E4-8400 client with E1-04 pre-supplied, so no extra read is queued."""
62
+ vfd = VesperE48400ModbusVFD("127.0.0.1", max_frequency_hz=50.0)
63
+ vfd._client = _StubClient(responses)
64
+ return vfd
65
+
66
+
67
+ # -- E5-8200 ----------------------------------------------------------------
68
+
69
+
70
+ def test_e5_read_parameters_survives_an_unreadable_monitor_register():
71
+ """A refused monitor read keeps the measurements and reports an unknown state."""
72
+ vfd = _e5([_read([5000, 3800, 540, 105]), _refused()])
73
+ parameters = asyncio.run(vfd.read_parameters())
74
+ assert parameters.frequency_hz == pytest.approx(50.0)
75
+ assert parameters.output_voltage == pytest.approx(380.0)
76
+ assert parameters.dc_bus_voltage == pytest.approx(540.0)
77
+ assert parameters.output_current == pytest.approx(10.5)
78
+ assert parameters.state is VFDState.UNKNOWN
79
+
80
+
81
+ def test_e5_read_parameters_still_raises_when_the_block_fails():
82
+ """The measurement block is mandatory: without it there is nothing to report."""
83
+ vfd = _e5([_refused()])
84
+ with pytest.raises(ModbusOperationError):
85
+ asyncio.run(vfd.read_parameters())
86
+
87
+
88
+ def test_e5_read_state_returns_unknown_when_the_monitor_register_is_refused():
89
+ """A refused monitor read is "unknown", not an exception."""
90
+ vfd = _e5([_refused()])
91
+ assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
92
+
93
+
94
+ # -- E4-8400 ----------------------------------------------------------------
95
+
96
+
97
+ def test_e4_read_parameters_survives_unreadable_diagnostics():
98
+ """Voltage, DC bus, temperature and state all degrade instead of raising."""
99
+ vfd = _e4(
100
+ [
101
+ _read([5000, 5000, 105, 0]), # setpoint, output freq, current
102
+ _refused(), # U1-06 output voltage
103
+ _refused(), # U1-07 DC bus voltage
104
+ _refused(), # U1-46 heatsink temperature
105
+ _refused(), # status
106
+ ]
107
+ )
108
+ parameters = asyncio.run(vfd.read_parameters())
109
+ assert parameters.frequency_hz == pytest.approx(50.0)
110
+ assert parameters.output_current == pytest.approx(10.5)
111
+ assert parameters.output_voltage == pytest.approx(0.0)
112
+ assert parameters.dc_bus_voltage is None
113
+ assert parameters.heatsink_temperature_c is None
114
+ assert parameters.state is VFDState.UNKNOWN
115
+
116
+
117
+ def test_e4_read_parameters_keeps_the_temperature_when_only_the_state_fails():
118
+ """One refused register must not blank the others."""
119
+ vfd = _e4(
120
+ [
121
+ _read([5000, 5000, 105, 0]),
122
+ _read([380]), # U1-06 output voltage
123
+ _read([540]), # U1-07 DC bus voltage
124
+ _read([42]), # U1-46 heatsink temperature
125
+ _refused(), # status
126
+ ]
127
+ )
128
+ parameters = asyncio.run(vfd.read_parameters())
129
+ assert parameters.output_voltage == pytest.approx(380.0)
130
+ assert parameters.dc_bus_voltage == pytest.approx(540.0)
131
+ assert parameters.heatsink_temperature_c == pytest.approx(42.0)
132
+ assert parameters.state is VFDState.UNKNOWN
133
+
134
+
135
+ def test_e4_read_parameters_still_raises_when_the_block_fails():
136
+ """The measurement block is mandatory: without it there is nothing to report."""
137
+ vfd = _e4([_refused()])
138
+ with pytest.raises(ModbusOperationError):
139
+ asyncio.run(vfd.read_parameters())
140
+
141
+
142
+ def test_e4_read_state_returns_unknown_when_the_status_register_is_refused():
143
+ """A refused status read is "unknown", not an exception."""
144
+ vfd = _e4([_refused()])
145
+ assert asyncio.run(vfd.read_state()) is VFDState.UNKNOWN
146
+
147
+
148
+ def test_e4_read_parameters_reads_e1_04_when_no_maximum_is_supplied():
149
+ """Without a constructor maximum the drive's own E1-04 is read first."""
150
+ vfd = VesperE48400ModbusVFD("127.0.0.1")
151
+ vfd._client = _StubClient(
152
+ [
153
+ _read([E4_MAX_FREQUENCY_RAW]), # E1-04
154
+ _read([5000, 2500, 105, 0]), # setpoint, output freq, current
155
+ _read([380]),
156
+ _read([540]),
157
+ _read([42]),
158
+ _read([0b011]), # running forward
159
+ ]
160
+ )
161
+ parameters = asyncio.run(vfd.read_parameters())
162
+ assert vfd._client.calls[0] == (
163
+ "read",
164
+ e4_registers.parameter_register(e4_registers.PARAM_MAX_FREQUENCY),
165
+ 1,
166
+ 1,
167
+ )
168
+ assert parameters.frequency_hz == pytest.approx(25.0)
169
+ assert parameters.frequency_percent == pytest.approx(50.0)
170
+ assert parameters.state is VFDState.RUNNING_FORWARD
171
+
172
+
173
+ def test_e5_parameter_block_addresses_are_unchanged():
174
+ """Guard the block the optional-read refactor reads from."""
175
+ assert e5_registers.PARAMETERS_BLOCK_ADDRESS == e5_registers.REG_OUTPUT_FREQUENCY
176
+ assert e5_registers.PARAMETERS_BLOCK_COUNT == 4
File without changes