scietex.hal.vfd 2.0.2__tar.gz → 2.1.0__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.2/src/scietex.hal.vfd.egg-info → scietex_hal_vfd-2.1.0}/PKG-INFO +15 -3
  2. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/README.md +14 -2
  3. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/base/data.py +12 -1
  4. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/spe_b/client.py +13 -0
  5. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/spe_b/registers.py +27 -0
  6. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/spe_b/rs485.py +12 -0
  7. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/version.py +1 -1
  8. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e4_8400/client.py +3 -1
  9. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e4_8400/registers.py +5 -0
  10. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e4_8400/rs485.py +3 -1
  11. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e5_8200/client.py +3 -1
  12. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e5_8200/rs485.py +3 -1
  13. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0/src/scietex.hal.vfd.egg-info}/PKG-INFO +15 -3
  14. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_modbus_client.py +32 -6
  15. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_registers.py +13 -0
  16. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/LICENSE +0 -0
  17. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/pyproject.toml +0 -0
  18. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/setup.cfg +0 -0
  19. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/__init__.py +0 -0
  20. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/base/__init__.py +0 -0
  21. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/base/capabilities.py +0 -0
  22. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/base/protocol.py +0 -0
  23. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/base/rs485.py +0 -0
  24. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/__init__.py +0 -0
  25. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/spe_b/__init__.py +0 -0
  26. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/intek/spe_b/errors.py +0 -0
  27. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/modbus_drivers.py +0 -0
  28. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/py.typed +0 -0
  29. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/__init__.py +0 -0
  30. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e4_8400/__init__.py +0 -0
  31. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e4_8400/errors.py +0 -0
  32. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e5_8200/__init__.py +0 -0
  33. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e5_8200/errors.py +0 -0
  34. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex/hal/vfd/vesper/e5_8200/registers.py +0 -0
  35. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex.hal.vfd.egg-info/SOURCES.txt +0 -0
  36. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex.hal.vfd.egg-info/dependency_links.txt +0 -0
  37. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex.hal.vfd.egg-info/entry_points.txt +0 -0
  38. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex.hal.vfd.egg-info/requires.txt +0 -0
  39. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/src/scietex.hal.vfd.egg-info/top_level.txt +0 -0
  40. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_modbus_drivers.py +0 -0
  41. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_version.py +0 -0
  42. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_vesper_e4_8400_registers.py +0 -0
  43. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_vesper_e5_8200_registers.py +0 -0
  44. {scietex_hal_vfd-2.0.2 → scietex_hal_vfd-2.1.0}/tests/test_vesper_optional_reads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scietex.hal.vfd
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Scietex VFD HAL package
5
5
  Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
6
  License: MIT License
@@ -71,8 +71,9 @@ A driver reaches a drive over one of two transports:
71
71
  unsupported command before dispatching it.
72
72
  - `VFDParameters`, which reports frequency in both Hz and percent of the
73
73
  configured maximum, plus output current, voltage and power, and the optional
74
- DC bus voltage and temperatures a drive exposes. An optional field is `None`
75
- when the drive does not report it, which is distinct from a reported `0.0`.
74
+ DC bus voltage, temperatures and run-time counters a drive exposes. An
75
+ optional field is `None` when the drive does not report it, which is distinct
76
+ from a reported `0.0`.
76
77
  - Implemented drive models, each with both transports:
77
78
  - `Intek SPE-B` — direct RS485 (`SPEvB`) and gateway (`IntekSPEBModbusVFD`).
78
79
  - `Vesper E5-8200` — direct RS485 (`VesperE58200`) and gateway
@@ -94,10 +95,21 @@ A driver reaches a drive over one of two transports:
94
95
  | `dc_bus_voltage` | yes | yes | yes |
95
96
  | `heatsink_temperature_c` | no (`None`) | no (`None`) | yes |
96
97
  | `motor_temperature_c` | no (`None`) | no (`None`) | no (`None`) |
98
+ | `total_run_hours` | yes | no (`None`) | no (`None`) |
99
+ | `total_power_on_hours` | yes | no (`None`) | no (`None`) |
97
100
 
98
101
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
99
102
  two report overheat only as a fault code, and a fault is not a temperature.
100
103
 
104
+ The SPE-B keeps two run-time counters (`0x101A` power-on, `0x101B` run). The
105
+ manual states no unit; the drive was measured incrementing the power-on counter
106
+ by exactly one over sixty seconds, so both are minutes and are reported as
107
+ hours. The E4-8400 defines U1-48 and U1-49 as "run time 1" and "run time 2"
108
+ without stating a unit or which is cumulative, and no drive was available to
109
+ measure them, so both hours fields stay `None` for that model. The E5-8200 has
110
+ no counter at all. A caller that needs hours for either drive accumulates them
111
+ itself.
112
+
101
113
  ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
102
114
  `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
103
115
  `P0.10` still reports `frequency_percent`, current, voltage and power; the
@@ -23,8 +23,9 @@ A driver reaches a drive over one of two transports:
23
23
  unsupported command before dispatching it.
24
24
  - `VFDParameters`, which reports frequency in both Hz and percent of the
25
25
  configured maximum, plus output current, voltage and power, and the optional
26
- DC bus voltage and temperatures a drive exposes. An optional field is `None`
27
- when the drive does not report it, which is distinct from a reported `0.0`.
26
+ DC bus voltage, temperatures and run-time counters a drive exposes. An
27
+ optional field is `None` when the drive does not report it, which is distinct
28
+ from a reported `0.0`.
28
29
  - Implemented drive models, each with both transports:
29
30
  - `Intek SPE-B` — direct RS485 (`SPEvB`) and gateway (`IntekSPEBModbusVFD`).
30
31
  - `Vesper E5-8200` — direct RS485 (`VesperE58200`) and gateway
@@ -46,10 +47,21 @@ A driver reaches a drive over one of two transports:
46
47
  | `dc_bus_voltage` | yes | yes | yes |
47
48
  | `heatsink_temperature_c` | no (`None`) | no (`None`) | yes |
48
49
  | `motor_temperature_c` | no (`None`) | no (`None`) | no (`None`) |
50
+ | `total_run_hours` | yes | no (`None`) | no (`None`) |
51
+ | `total_power_on_hours` | yes | no (`None`) | no (`None`) |
49
52
 
50
53
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
51
54
  two report overheat only as a fault code, and a fault is not a temperature.
52
55
 
56
+ The SPE-B keeps two run-time counters (`0x101A` power-on, `0x101B` run). The
57
+ manual states no unit; the drive was measured incrementing the power-on counter
58
+ by exactly one over sixty seconds, so both are minutes and are reported as
59
+ hours. The E4-8400 defines U1-48 and U1-49 as "run time 1" and "run time 2"
60
+ without stating a unit or which is cumulative, and no drive was available to
61
+ measure them, so both hours fields stay `None` for that model. The E5-8200 has
62
+ no counter at all. A caller that needs hours for either drive accumulates them
63
+ itself.
64
+
53
65
  ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
54
66
  `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
55
67
  `P0.10` still reports `frequency_percent`, current, voltage and power; the
@@ -62,6 +62,13 @@ class VFDParameters(msgspec.Struct, frozen=True):
62
62
  motor_temperature_c: Motor winding or PTC temperature in degrees
63
63
  Celsius, or ``None`` when the drive does not report it.
64
64
  state: Current VFD state.
65
+ total_run_hours: Cumulative motor run time in hours, or ``None`` when
66
+ the drive keeps no such counter. A drive that reports the counter
67
+ but refuses the read also yields ``None``: the caller cannot tell
68
+ "no counter" from "unreadable", and both mean the value must come
69
+ from somewhere else.
70
+ total_power_on_hours: Cumulative powered-on time in hours, or ``None``
71
+ when the drive keeps no such counter.
65
72
  """
66
73
 
67
74
  frequency_hz: float | None # Current operating frequency in Hz
@@ -73,6 +80,8 @@ class VFDParameters(msgspec.Struct, frozen=True):
73
80
  heatsink_temperature_c: float | None = None # Power-module temperature, deg C
74
81
  motor_temperature_c: float | None = None # Motor temperature, deg C
75
82
  state: VFDState = VFDState.STOPPED # Current VFD state
83
+ total_run_hours: float | None = None # Cumulative run time, hours
84
+ total_power_on_hours: float | None = None # Cumulative power-on time, hours
76
85
 
77
86
  def __str__(self) -> str:
78
87
  return (
@@ -84,7 +93,9 @@ class VFDParameters(msgspec.Struct, frozen=True):
84
93
  f"dc_bus={self.dc_bus_voltage} V, "
85
94
  f"heatsink={self.heatsink_temperature_c} C, "
86
95
  f"motor={self.motor_temperature_c} C, "
87
- f"state={self.state})"
96
+ f"state={self.state}, "
97
+ f"run_hours={self.total_run_hours}, "
98
+ f"power_on_hours={self.total_power_on_hours})"
88
99
  )
89
100
 
90
101
 
@@ -270,6 +270,10 @@ class IntekSPEBModbusVFD(VFDProtocol):
270
270
  the status register still reports its measurements, so the state falls
271
271
  back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
272
272
 
273
+ The two run-time counters are read as a second block and are optional
274
+ for the same reason: a drive that refuses them still reports its
275
+ measurements, and the hours fields stay ``None``.
276
+
273
277
  Returns:
274
278
  VFDParameters: The decoded parameters. Both temperature fields are
275
279
  ``None``: SPE-B reports overheat as a fault code, not a reading.
@@ -287,6 +291,9 @@ class IntekSPEBModbusVFD(VFDProtocol):
287
291
  block[registers.REG_OUTPUT_CURRENT - registers.PARAMETERS_BLOCK_ADDRESS]
288
292
  * self._current_resolution
289
293
  )
294
+ run_time = await self._read_optional_registers(
295
+ registers.RUN_TIME_BLOCK_ADDRESS, registers.RUN_TIME_BLOCK_COUNT
296
+ )
290
297
  parameters = VFDParameters(
291
298
  frequency_hz=self._percent_to_hz(percent),
292
299
  frequency_percent=percent,
@@ -301,6 +308,12 @@ class IntekSPEBModbusVFD(VFDProtocol):
301
308
  block[registers.REG_DC_BUS_VOLTAGE - registers.PARAMETERS_BLOCK_ADDRESS]
302
309
  ),
303
310
  state=await self.read_state(),
311
+ total_power_on_hours=(
312
+ registers.run_time_to_hours(run_time[0]) if run_time is not None else None
313
+ ),
314
+ total_run_hours=(
315
+ registers.run_time_to_hours(run_time[1]) if run_time is not None else None
316
+ ),
304
317
  )
305
318
  self.logger.debug("%s: %s", self.label, parameters)
306
319
  return parameters
@@ -54,6 +54,16 @@ REG_AUX_FREQUENCY_Y = 0x1020 # Auxiliary frequency Y, raw
54
54
  PARAMETERS_BLOCK_ADDRESS = REG_OPERATING_FREQUENCY
55
55
  PARAMETERS_BLOCK_COUNT = 5
56
56
 
57
+ # The two consecutive run-time counters, read as a second block because they sit
58
+ # outside the measurement block. The manual calls them "raw" and states no unit;
59
+ # the drive was measured incrementing 0x101A by one per minute, so both are
60
+ # minutes. `0x1015` (remaining run time) is deliberately not read: it is the
61
+ # countdown to a maintenance interval, not a total, and it reads 0 when no
62
+ # interval is configured.
63
+ RUN_TIME_BLOCK_ADDRESS = REG_TOTAL_POWER_ON_TIME
64
+ RUN_TIME_BLOCK_COUNT = 2
65
+ RUN_TIME_RESOLUTION = 60.0 # Counter units per hour
66
+
57
67
  # Control register, FC06 write only.
58
68
  REG_CONTROL = 0x2000
59
69
  CONTROL_FORWARD_RUN = 1
@@ -185,6 +195,23 @@ def raw_to_percent(raw: int) -> float:
185
195
  return raw / ANALOG_OUTPUT_MAX_RAW * 100.0
186
196
 
187
197
 
198
+ def run_time_to_hours(raw: int) -> float:
199
+ """
200
+ Convert a run-time counter register into hours.
201
+
202
+ The manual states no unit for `REG_TOTAL_POWER_ON_TIME` and
203
+ `REG_TOTAL_RUN_TIME`. The drive was measured incrementing the power-on
204
+ counter by exactly one over sixty seconds, so the counters are minutes.
205
+
206
+ Args:
207
+ raw: The register value.
208
+
209
+ Returns:
210
+ float: The counter as hours.
211
+ """
212
+ return raw / RUN_TIME_RESOLUTION
213
+
214
+
188
215
  def status_to_state(status: int) -> VFDState:
189
216
  """
190
217
  Decode the status register into a `VFDState`.
@@ -127,6 +127,12 @@ class SPEvB(VFD):
127
127
  else None
128
128
  )
129
129
  state = await self.read_state()
130
+ run_time = await self.read_registers(
131
+ registers.RUN_TIME_BLOCK_ADDRESS,
132
+ registers.RUN_TIME_BLOCK_COUNT,
133
+ holding=True,
134
+ raise_on_error=False,
135
+ )
130
136
  return VFDParameters(
131
137
  frequency_hz=frequency_hz,
132
138
  frequency_percent=percent,
@@ -141,6 +147,12 @@ class SPEvB(VFD):
141
147
  block[registers.REG_DC_BUS_VOLTAGE - registers.PARAMETERS_BLOCK_ADDRESS]
142
148
  ),
143
149
  state=state,
150
+ total_power_on_hours=(
151
+ registers.run_time_to_hours(run_time[0]) if run_time is not None else None
152
+ ),
153
+ total_run_hours=(
154
+ registers.run_time_to_hours(run_time[1]) if run_time is not None else None
155
+ ),
144
156
  )
145
157
 
146
158
  async def read_state(self) -> VFDState:
@@ -5,4 +5,4 @@ Attributes:
5
5
  __version__ (str): The current version of the package.
6
6
  """
7
7
 
8
- __version__ = "2.0.2"
8
+ __version__ = "2.1.0"
@@ -254,7 +254,9 @@ class VesperE48400ModbusVFD(VFDProtocol):
254
254
  Returns:
255
255
  VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
256
256
  populated from U1-46; ``motor_temperature_c`` stays ``None``
257
- because the drive reports motor overheat only as a fault.
257
+ because the drive reports motor overheat only as a fault. Both
258
+ hours fields stay ``None``: U1-48 and U1-49 are undocumented as
259
+ to unit and meaning, so the caller accumulates the hours.
258
260
 
259
261
  Raises:
260
262
  ModbusOperationError: If the measurement block or E1-04 cannot be
@@ -109,6 +109,11 @@ REG_COUNTER = 0x004E # U1-47, counter value
109
109
  REG_RUN_TIME_1 = 0x004F # U1-48, run time 1
110
110
  REG_RUN_TIME_2 = 0x0050 # U1-49, run time 2
111
111
 
112
+ # U1-48 and U1-49 are not read: the manual names them "run time 1" and "run time
113
+ # 2" without stating a unit or which one is cumulative, and no drive was
114
+ # available to measure them. `VFDParameters.total_run_hours` therefore stays
115
+ # ``None`` for this model and the caller accumulates the hours itself.
116
+
112
117
  # Fault snapshot, read only. The values are captured when the last fault
113
118
  # occurred and are not updated while the drive runs.
114
119
  REG_FAULT_OUTPUT_FREQUENCY = 0x0083 # U2-04, output frequency at fault
@@ -206,7 +206,9 @@ class VesperE48400(VFD):
206
206
  Returns:
207
207
  VFDParameters: The decoded parameters. ``heatsink_temperature_c`` is
208
208
  populated from U1-46; ``motor_temperature_c`` stays ``None``
209
- because the drive reports motor overheat only as a fault.
209
+ because the drive reports motor overheat only as a fault. Both
210
+ hours fields stay ``None``: U1-48 and U1-49 are undocumented as
211
+ to unit and meaning, so the caller accumulates the hours.
210
212
 
211
213
  Raises:
212
214
  ModbusOperationError: If the measurement block or E1-04 cannot be
@@ -228,7 +228,9 @@ class VesperE58200ModbusVFD(VFDProtocol):
228
228
  back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
229
229
 
230
230
  Returns:
231
- VFDParameters: The decoded parameters.
231
+ VFDParameters: The decoded parameters. Both hours fields stay
232
+ ``None``: the drive exposes no run-time counter, so the caller
233
+ accumulates the hours.
232
234
 
233
235
  Raises:
234
236
  ModbusOperationError: If the measurement block cannot be read.
@@ -138,7 +138,9 @@ class VesperE58200(VFD):
138
138
  back to ``VFDState.UNKNOWN`` instead of failing the whole snapshot.
139
139
 
140
140
  Returns:
141
- VFDParameters: The decoded parameters.
141
+ VFDParameters: The decoded parameters. Both hours fields stay
142
+ ``None``: the drive exposes no run-time counter, so the caller
143
+ accumulates the hours.
142
144
 
143
145
  Raises:
144
146
  ModbusOperationError: If the measurement block cannot be read.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scietex.hal.vfd
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Scietex VFD HAL package
5
5
  Author-email: Anton Bondarenko <bond.anton@gmail.com>
6
6
  License: MIT License
@@ -71,8 +71,9 @@ A driver reaches a drive over one of two transports:
71
71
  unsupported command before dispatching it.
72
72
  - `VFDParameters`, which reports frequency in both Hz and percent of the
73
73
  configured maximum, plus output current, voltage and power, and the optional
74
- DC bus voltage and temperatures a drive exposes. An optional field is `None`
75
- when the drive does not report it, which is distinct from a reported `0.0`.
74
+ DC bus voltage, temperatures and run-time counters a drive exposes. An
75
+ optional field is `None` when the drive does not report it, which is distinct
76
+ from a reported `0.0`.
76
77
  - Implemented drive models, each with both transports:
77
78
  - `Intek SPE-B` — direct RS485 (`SPEvB`) and gateway (`IntekSPEBModbusVFD`).
78
79
  - `Vesper E5-8200` — direct RS485 (`VesperE58200`) and gateway
@@ -94,10 +95,21 @@ A driver reaches a drive over one of two transports:
94
95
  | `dc_bus_voltage` | yes | yes | yes |
95
96
  | `heatsink_temperature_c` | no (`None`) | no (`None`) | yes |
96
97
  | `motor_temperature_c` | no (`None`) | no (`None`) | no (`None`) |
98
+ | `total_run_hours` | yes | no (`None`) | no (`None`) |
99
+ | `total_power_on_hours` | yes | no (`None`) | no (`None`) |
97
100
 
98
101
  Only the E4-8400 exposes a live temperature reading (`004Dh`, U1-46). The other
99
102
  two report overheat only as a fault code, and a fault is not a temperature.
100
103
 
104
+ The SPE-B keeps two run-time counters (`0x101A` power-on, `0x101B` run). The
105
+ manual states no unit; the drive was measured incrementing the power-on counter
106
+ by exactly one over sixty seconds, so both are minutes and are reported as
107
+ hours. The E4-8400 defines U1-48 and U1-49 as "run time 1" and "run time 2"
108
+ without stating a unit or which is cumulative, and no drive was available to
109
+ measure them, so both hours fields stay `None` for that model. The E5-8200 has
110
+ no counter at all. A caller that needs hours for either drive accumulates them
111
+ itself.
112
+
101
113
  ¹ The SPE-B reports the operating frequency only as a percentage of `P0.10`, so
102
114
  `frequency_hz` is `None` until `P0.10` has been read. A drive that refuses
103
115
  `P0.10` still reports `frequency_percent`, current, voltage and power; the
@@ -132,8 +132,8 @@ def test_read_before_connect_raises():
132
132
 
133
133
 
134
134
  def test_read_parameters_scales_percent_current_and_reads_state():
135
- """One block read at 0x1001, then the status register."""
136
- vfd, stub = _connected([_read([5000, 540, 380, 123, 45]), _read([1])])
135
+ """One block read at 0x1001, the run-time block, then the status register."""
136
+ vfd, stub = _connected([_read([5000, 540, 380, 123, 45]), _read([3231, 120]), _read([1])])
137
137
  parameters = asyncio.run(vfd.read_parameters())
138
138
 
139
139
  assert stub.calls == [
@@ -143,6 +143,7 @@ def test_read_parameters_scales_percent_current_and_reads_state():
143
143
  registers.PARAMETERS_BLOCK_COUNT,
144
144
  1,
145
145
  ),
146
+ ("read", registers.RUN_TIME_BLOCK_ADDRESS, registers.RUN_TIME_BLOCK_COUNT, 1),
146
147
  ("read", registers.REG_STATUS, 1, 1),
147
148
  ]
148
149
  assert parameters.frequency_percent == pytest.approx(50.0)
@@ -151,22 +152,36 @@ def test_read_parameters_scales_percent_current_and_reads_state():
151
152
  assert parameters.output_voltage == pytest.approx(380.0)
152
153
  assert parameters.output_power == pytest.approx(45.0)
153
154
  assert parameters.state is VFDState.RUNNING_FORWARD
155
+ assert parameters.total_power_on_hours == pytest.approx(53.85)
156
+ assert parameters.total_run_hours == pytest.approx(2.0)
154
157
 
155
158
 
156
159
  def test_read_parameters_uses_the_coarse_current_resolution():
157
160
  """Pd.06 = 1 means 0.1 A per count."""
158
- vfd, _ = _connected([_read([0, 0, 0, 123, 0]), _read([3])], current_raw=1)
161
+ vfd, _ = _connected([_read([0, 0, 0, 123, 0]), _read([0, 0]), _read([3])], current_raw=1)
159
162
  assert asyncio.run(vfd.read_parameters()).output_current == pytest.approx(12.3)
160
163
 
161
164
 
162
165
  def test_read_parameters_reports_no_hz_when_p010_is_zero():
163
166
  """An unusable maximum frequency keeps the percentage but cannot scale it."""
164
- vfd, _ = _connected([_read([5000, 0, 0, 0, 0]), _read([3])], max_frequency_raw=0)
167
+ vfd, _ = _connected([_read([5000, 0, 0, 0, 0]), _read([0, 0]), _read([3])], max_frequency_raw=0)
165
168
  parameters = asyncio.run(vfd.read_parameters())
166
169
  assert parameters.frequency_percent == pytest.approx(50.0)
167
170
  assert parameters.frequency_hz is None
168
171
 
169
172
 
173
+ def test_read_parameters_survives_an_unreadable_run_time_block():
174
+ """A refused run-time block leaves the hours unknown, not the snapshot empty."""
175
+ vfd, _ = _connected(
176
+ [_read([5000, 540, 380, 123, 45]), ExceptionResponse(function_code=3), _read([1])]
177
+ )
178
+ parameters = asyncio.run(vfd.read_parameters())
179
+ assert parameters.frequency_hz == pytest.approx(25.0)
180
+ assert parameters.total_run_hours is None
181
+ assert parameters.total_power_on_hours is None
182
+ assert parameters.state is VFDState.RUNNING_FORWARD
183
+
184
+
170
185
  def test_connect_survives_a_refused_p010_and_still_reports_measurements():
171
186
  """A drive that refuses P0.10 must not blank the whole snapshot.
172
187
 
@@ -181,6 +196,7 @@ def test_connect_survives_a_refused_p010_and_still_reports_measurements():
181
196
  ExceptionResponse(function_code=3), # P0.10
182
197
  _read([1]), # Pd.06 = coarse, 0.1 A
183
198
  _read([5000, 540, 380, 123, 45]),
199
+ _read([3231, 120]), # run-time block
184
200
  _read([1]), # status: running forward
185
201
  ]
186
202
  )
@@ -194,6 +210,7 @@ def test_connect_survives_a_refused_p010_and_still_reports_measurements():
194
210
  assert parameters.output_voltage == pytest.approx(380.0)
195
211
  assert parameters.dc_bus_voltage == pytest.approx(540.0)
196
212
  assert parameters.state is VFDState.RUNNING_FORWARD
213
+ assert parameters.total_run_hours == pytest.approx(2.0)
197
214
 
198
215
 
199
216
  def test_set_frequency_still_refuses_when_p010_is_unknown():
@@ -210,12 +227,19 @@ def test_read_parameters_survives_an_unreadable_status_register():
210
227
  would mean a drive that refuses one diagnostic register never reports its
211
228
  frequency, current or voltage at all.
212
229
  """
213
- vfd, _ = _connected([_read([5000, 540, 380, 123, 45]), ExceptionResponse(function_code=3)])
230
+ vfd, _ = _connected(
231
+ [
232
+ _read([5000, 540, 380, 123, 45]),
233
+ _read([3231, 120]),
234
+ ExceptionResponse(function_code=3),
235
+ ]
236
+ )
214
237
  parameters = asyncio.run(vfd.read_parameters())
215
238
  assert parameters.frequency_hz == pytest.approx(25.0)
216
239
  assert parameters.output_current == pytest.approx(1.23)
217
240
  assert parameters.output_voltage == pytest.approx(380.0)
218
241
  assert parameters.state is VFDState.UNKNOWN
242
+ assert parameters.total_run_hours == pytest.approx(2.0)
219
243
 
220
244
 
221
245
  def test_read_parameters_still_raises_when_the_measurement_block_fails():
@@ -332,7 +356,7 @@ def test_write_raises_on_an_exception_response():
332
356
 
333
357
  def test_read_data_returns_the_parameter_fields():
334
358
  """`read_data` flattens `VFDParameters` into a plain mapping."""
335
- vfd, _ = _connected([_read([5000, 0, 380, 100, 10]), _read([2])])
359
+ vfd, _ = _connected([_read([5000, 0, 380, 100, 10]), _read([3231, 120]), _read([2])])
336
360
  data = asyncio.run(vfd.read_data())
337
361
  assert data == {
338
362
  "frequency_hz": pytest.approx(25.0),
@@ -344,4 +368,6 @@ def test_read_data_returns_the_parameter_fields():
344
368
  "heatsink_temperature_c": None,
345
369
  "motor_temperature_c": None,
346
370
  "state": VFDState.RUNNING_BACKWARD,
371
+ "total_run_hours": pytest.approx(2.0),
372
+ "total_power_on_hours": pytest.approx(53.85),
347
373
  }
@@ -39,6 +39,19 @@ def test_parameters_block_starts_at_the_operating_frequency():
39
39
  )
40
40
 
41
41
 
42
+ def test_run_time_block_spans_the_two_counters():
43
+ """Power-on time is 0x101A and run time 0x101B, so one read covers both."""
44
+ assert registers.RUN_TIME_BLOCK_ADDRESS == 0x101A
45
+ assert registers.RUN_TIME_BLOCK_COUNT == 2
46
+ assert registers.RUN_TIME_BLOCK_ADDRESS + registers.RUN_TIME_BLOCK_COUNT - 1 == 0x101B
47
+
48
+
49
+ @pytest.mark.parametrize("raw, expected_hours", [(0, 0.0), (60, 1.0), (3231, 53.85)])
50
+ def test_run_time_to_hours(raw, expected_hours):
51
+ """The counters are minutes: the drive was measured incrementing by one per minute."""
52
+ assert registers.run_time_to_hours(raw) == pytest.approx(expected_hours)
53
+
54
+
42
55
  @pytest.mark.parametrize(
43
56
  "percent, expected_hz",
44
57
  [(100.0, 50.0), (50.0, 25.0), (0.0, 0.0), (-100.0, -50.0), (200.0, 100.0)],
File without changes