Files
modbus-range-limiter/test_modbus_limiter.py
T

630 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Modbus Range Limiter Test Script (RPU + TPU)
=============================================
Auto-detects device type by reading HARDWARE_REV_HI (holding reg 163):
0x0021 → TPU (fenghuang-dsp)
0x0022 → RPU (garuda-dsp)
RPU trigger: write MAIN_FORCE_NORMAL_MODE password (addrs 48/49).
Firmware calls modbus_to_config() → LimitModbusRange() → write_all_flash() → soft_reset().
TPU trigger: write holding coil bit 49 (MODBUS_ALL_CFG_CMD_BIT, FC5).
update_modbus_input() detects write_flash==1 → modbus_to_config() → LimitModbusRange() → write_all_flash() → soft_reset().
Both write_all_flash() implementations call DINT then soft_reset() — the device
reboots immediately; the Modbus ACK for the trigger write may never arrive.
NoResponseError on the trigger write is expected and means the rewrite WAS triggered.
Data types:
'uint16' — single holding register, unsigned 16-bit (FC3 read / FC6 write)
'int16' — single holding register, signed 16-bit
'float32' — two consecutive holding registers, IEEE 754 single-precision,
stored big-endian (high word first) via EndianConvert32Bits.
Read with FC3 / write with FC16. Comparison uses FLOAT_EPSILON tolerance.
TPU float calibration limits are fixed constants from fenghuang-dsp/app/main/global_variables.h:
Gain limits = [nominal × 0.9, nominal × 1.1]
Offset limits = [nominal × 0.8, nominal × 1.2]
(For negative offset defaults this gives an inverted range; firmware clamping is verified as-is.)
PFC_current_rms_cali_B excluded (offset nominal = 0.0 → trivial limits).
Test phases (batch approach):
Phase 0: Read and save all defaults.
Phase 1: Write ALL below-min values → single trigger → read all back.
Phase 2: Write ALL above-max values → single trigger → read all back.
Restore: Write all defaults back → trigger.
Output: timestamped CSV report.
"""
import csv
import os
import time
import minimalmodbus
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
SLAVE_ID = 16 # DEFAULT_SLAVE_ID = 0x10
BAUD = 9600
TIMEOUT = 2 # seconds
REWRITE_WAIT = 10.0 # write_all_flash() → soft_reset() → full reboot; allow time
FLOAT_EPSILON = 1e-4 # relative tolerance for float readback comparison (0.01%)
# RPU trigger: MAIN_FORCE_NORMAL_MODE password (garuda-dsp modbus_mapping.h)
ADDR_PASSWORD_LO = 48 # MODBUS_FORCE_PASSWORD_LO
ADDR_PASSWORD_HI = 49 # MODBUS_FORCE_PASSWORD_HI
PASSWORD_LO = 0xFEED
PASSWORD_HI = 0xCEE5
# TPU trigger: holding coil bit 49 (fenghuang-dsp modbus_mapping.h)
TPU_BIT_WRITE_FLASH = 49 # MODBUS_ALL_CFG_CMD_BIT → main_app.write_flash
# TPU state register: input reg 61 (MODBUS_MAIN_STATE), read via FC4.
# Firmware only processes write_flash in these states (app_modbus.c update_modbus_input).
ADDR_TPU_STATE = 61
TPU_TRIGGER_ALLOWED_STATES = {
2, # TPU_READY
10, # TPU_INITIALIZATION
15, # TPU_DEBUG_CONFIG
16, # TPU_TEST_MODE
0xEE, # TPU_ERROR
}
# Auto-detection register (holding reg 163, same address for both RPU and TPU)
ADDR_HARDWARE_REV_HI = 163 # MODBUS_HARDWARE_REVISION_HI
HW_REV_TPU = 0x0021
HW_REV_RPU = 0x0022
# ---------------------------------------------------------------------------
# RPU register test list (garuda-dsp)
# ---------------------------------------------------------------------------
# Columns: (label, addr, data_type, min_val, max_val, below_min_input, above_max_input)
#
# Limits source: garuda-dsp app/app_modbus/app_modbus.h + global_variables.h
RPU_TESTS = [
# ----- VI operation settings -----
('Voltage_setting_Q128', 12, 'uint16', 0, 8320, None, 8321),
('Current_setting_Q128', 14, 'uint16', 0, 3840, None, 3841),
('Power_limit_ref', 15, 'uint16', 0, 1500, None, 1501),
('No_Battery_Voltage_ref_Q128', 141, 'uint16', 0, 8320, None, 8321),
# ----- Protection thresholds -----
('Cutoff_voltage_Q128', 50, 'uint16', 0, 8320, None, 8321),
('OVP_setting_Q128', 11, 'uint16', 0, 8320, None, 8321),
('Cutoff_over_current_Q128', 10, 'uint16', 0, 4480, None, 4481),
('Cutoff_minimum_current_Q128', 51, 'uint16', 0, 640, None, 641),
# ----- Precharge / CV current -----
('Precharge_current', 6, 'int16', 0, 1280, -1, 1281),
('CV_current_limit', 7, 'int16', 0, 1280, -1, 1281),
# ----- SR control -----
('SR_limit_lo', 16, 'uint16', 0, 1280, None, 1281),
('SR_limit_hi', 17, 'uint16', 0, 1280, None, 1281),
('SR_duty_ref', 89, 'uint16', 20, 50, 19, 51),
('SR_delay_ref', 91, 'int16', 0, 100, -1, 101),
# ----- ADC calibration -----
('Adc_cali_a_Iout', 20, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Iout', 21, 'int16', -32767, 32767, -32768, None),
('Adc_cali_a_Vdcbus', 22, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Vdcbus', 23, 'int16', -32767, 32767, -32768, None),
('Adc_cali_a_Vbatt', 24, 'int16', 950, 1100, 949, 1101),
('Adc_cali_b_Vbatt', 25, 'int16', -32767, 32767, -32768, None),
# ----- CAN / Comm -----
('CAN_bit_rate_kbps', 100, 'uint16', 100, 1000, 99, 1001),
('CAN_protocol_select', 125, 'uint16', 0, 101, None, 102),
# ('comm_protocol', 126, 'uint16', 0, 1, None, 2), # EXCLUDED: comm_protocol=1 disables Modbus → device becomes unreachable after rewrite
('CAN_node_ID', 138, 'uint16', 1, 127, 0, 128),
# ----- BLE -----
('BLE_Conn_mode', 142, 'uint16', 0, 2, None, 3),
('RSSI_auto_threshold', 105, 'int16', -120, 0, -121, 1),
('RSSI_max_voltage_Q128', 106, 'uint16', 0, 8320, None, 8321),
# ----- Temperature thresholds — dev -----
('Dev_temp_thres_stage_1', 42, 'int16', -40, 150, -41, 151),
('Dev_temp_thres_stage_2', 43, 'int16', -40, 150, -41, 151),
('Dev_temp_thres_stage_3', 44, 'int16', -40, 150, -41, 151),
# ----- Temperature thresholds — coil -----
('Coil_temp_thres_stage_1', 107, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_stage_2', 108, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_stage_3', 109, 'int16', -40, 150, -41, 151),
# ----- Temperature thresholds — MCU -----
('MCU_temp_thres_stage_1', 92, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_stage_2', 93, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_stage_3', 94, 'int16', -40, 150, -41, 151),
# ----- Current derating (%) -----
('Current_derating_1', 45, 'uint16', 0, 50, None, 51),
('Current_derating_2', 46, 'uint16', 0, 50, None, 51),
# ----- Ramp control -----
('Ramp_control_mA_per_sec', 111, 'uint16', 0, 50000, None, 50001),
('Ramp_slow_mA_per_sec', 136, 'uint16', 0, 50000, None, 50001),
('Ramp_current_dV', 137, 'uint16', 0, 1280, None, 1281),
# ----- Discovery / height -----
('Discovery_Timeout_sec', 128, 'uint16', 0, 120, None, 121),
('RPU_Height_mm', 98, 'uint16', 0, 1500, None, 1501),
]
# ---------------------------------------------------------------------------
# TPU register test list (fenghuang-dsp)
# ---------------------------------------------------------------------------
# Limits source: fenghuang-dsp app/app_modbus/app_modbus.h, global_variables.h,
# sec_control.h, fan.h, app_inv.h
#
# float32: two consecutive holding registers (EndianConvert32Bits → BYTEORDER_BIG).
# Written via FC16 (write multiple registers).
#
# Calibration gain (A): limits = [nominal × 0.9, nominal × 1.1]
# Calibration offset (B) with positive nominal: limits = [nominal × 0.8, nominal × 1.2]
# Calibration offset (B) with negative nominal: limits are inverted in firmware
# (lo_limit = nominal×0.8, up_limit = nominal×1.2 where lo > up numerically).
# min_val = lo_limit (expected clamp when src too negative),
# max_val = up_limit (expected clamp when src too positive/less negative).
# PFC_current_rms_cali_B excluded (nominal = 0.0).
TPU_TESTS = [
# ----- PFC power (uint16) -----
('Power_limit', 47, 'uint16', 0, 1900, None, 1901),
# ----- Coil voltage max (uint16) -----
('Coil_voltage_max', 78, 'uint16', 0, 1000, None, 1001),
# ----- RPU setpoints (uint16) -----
('RPU_current_set_Q128', 98, 'uint16', 0, 3840, None, 3841),
('RPU_voltage_set_Q128', 99, 'uint16', 0, 8320, None, 8321),
# ----- Fan control (uint16 / int16) -----
('Fan_mode', 69, 'uint16', 0, 255, None, 256),
('Fan_duty_set', 70, 'uint16', 0, 100, None, 101),
('Fan_temp_lo', 71, 'int16', -40, 80, -41, 81),
('Fan_temp_hi', 72, 'int16', -40, 80, -41, 81),
# ----- Moving-away detection (uint16) -----
('Moving_away_time_ms', 3, 'uint16', 50, 500, 49, 501),
('Move_away_iout_thres', 84, 'uint16', 0, 50, None, 51),
# ----- OVP (uint16) -----
('OVP_iinv_pk_threshold', 75, 'uint16', 0, 50, None, 51),
('OVP_phase_threshold', 76, 'uint16', 0, 500, None, 501),
('OVP_count_threshold', 77, 'uint16', 0, 10, None, 11),
# ----- Phase command min (uint16) -----
('Phase_cmd_min', 101, 'uint16', 0, 750, None, 751),
# ----- No-batt coupling ramp (uint16) -----
('No_batt_coupling_ramp_phase', 109, 'uint16', 0, 300, None, 301),
# ----- BLE (uint16) -----
('BLE_discon_timeout_ms', 2, 'uint16', 0, 60000, None, 60001),
('BLE_mode', 5, 'uint16', 0, 2, None, 3),
# ----- Current derating (uint16, stored as integer cast from float) -----
('Current_derating_1', 107, 'uint16', 0, 50, None, 51),
('Current_derating_2', 108, 'uint16', 0, 50, None, 51),
# ----- INV temperature thresholds (int16) -----
('INV_temp_thres_1', 60, 'int16', -40, 150, -41, 151),
('INV_temp_thres_2', 61, 'int16', -40, 150, -41, 151),
('INV_temp_thres_3', 62, 'int16', -40, 150, -41, 151),
# ----- PFC temperature thresholds (int16) -----
('PFC_temp_thres_1', 63, 'int16', -40, 150, -41, 151),
('PFC_temp_thres_2', 64, 'int16', -40, 150, -41, 151),
('PFC_temp_thres_3', 65, 'int16', -40, 150, -41, 151),
# ----- Coil temperature thresholds (int16) -----
('Coil_temp_thres_1', 66, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_2', 67, 'int16', -40, 150, -41, 151),
('Coil_temp_thres_3', 68, 'int16', -40, 150, -41, 151),
# ----- MCU temperature thresholds (int16) -----
('MCU_temp_thres_1', 104, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_2', 105, 'int16', -40, 150, -41, 151),
('MCU_temp_thres_3', 106, 'int16', -40, 150, -41, 151),
# ----- Coupling estimation frequencies (float32) -----
# INV_FREQ_MIN = 60.0 Hz, INV_FREQ_MAX = 150.0 Hz (app_inv.h)
('Coupling_est_f_lo', 6, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_f_hi', 8, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_f3', 102, 'float32', 60.0, 150.0, 59.0, 151.0),
('Coupling_est_iinv_ref', 10, 'float32', 0.0, 40.0, -1.0, 41.0),
# ----- INV frequency settings (float32) -----
('INV_freq_set', 28, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_freq_min', 30, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_freq_max', 32, 'float32', 60.0, 150.0, 59.0, 151.0),
('INV_current_limit', 34, 'float32', 0.0, 40.0, -1.0, 41.0),
# ----- PFC voltage / current (float32) -----
# MODBUS_LIMIT_PFC_DC_BUS_VOLTAGE_MAX_CEIL = 450.0, PFC_CURRENT_MAX_CEIL = 40.0
('PFC_vdc_ref', 40, 'float32', 0.0, 450.0, -1.0, 451.0),
('PFC_voltage_limit', 42, 'float32', 0.0, 450.0, -1.0, 451.0),
('PFC_current_limit', 44, 'float32', 0.0, 40.0, -1.0, 41.0),
# ----- Protection current / voltage limits (float32) -----
('PFC_current_max', 48, 'float32', 0.0, 40.0, -1.0, 41.0),
('INV_current_max', 50, 'float32', 0.0, 40.0, -1.0, 41.0),
('INV_current_min', 170, 'float32', 0.0, 40.0, -1.0, 41.0),
('DC_bus_voltage_min', 52, 'float32', 0.0, 450.0, -1.0, 451.0),
('DC_bus_voltage_max', 54, 'float32', 0.0, 450.0, -1.0, 451.0),
# MODBUS_LIMIT_AC_IN_VOLTAGE_MAX_CEIL = 400.0
('AC_in_voltage_min', 56, 'float32', 0.0, 400.0, -1.0, 401.0),
('AC_in_voltage_max', 58, 'float32', 0.0, 400.0, -1.0, 401.0),
# ----- No-batt coupling INV current (float32) -----
('No_batt_coupling_inv_current', 168, 'float32', 0.0, 40.0, -1.0, 41.0),
# ----- ADC calibration gain (A) registers (float32) -----
# Limits = [nominal × 0.9, nominal × 1.1] (compile-time constants in firmware)
# Nominal values from global_variables.h:
# ANA_PFC_CURRENT_GAIN = 0.022127795
# ANA_INV_CURRENT_GAIN = 0.024940704
# ANA_DC_BUS_VOLTAGE_GAIN = 0.118472142
# ANA_AC_IN_VOLTAGE_GAIN = 0.118678347
# ANA_COIL_VOLTAGE_GAIN = 0.191968026
# ANA_PFC_CURRENT_RMS_GAIN = 1.0
('PFC_current_cali_A', 12, 'float32', 0.019915, 0.024341, 0.018809, 0.025447),
('INV_current_cali_A', 16, 'float32', 0.022447, 0.027435, 0.021200, 0.028683),
('DC_bus_voltage_cali_A', 20, 'float32', 0.106625, 0.130319, 0.100701, 0.136243),
('AC_in_voltage_cali_A', 24, 'float32', 0.106811, 0.130546, 0.100877, 0.136480),
('Coil_voltage_cali_A', 80, 'float32', 0.172771, 0.211165, 0.163173, 0.220763),
('PFC_current_rms_cali_A', 110, 'float32', 0.9, 1.1, 0.85, 1.15),
# ----- ADC calibration offset (B) registers (float32) -----
# Limits = [nominal × 0.8, nominal × 1.2]
# Nominal values from global_variables.h:
# ANA_PFC_CURRENT_OFFSET = -0.469129801 (negative → inverted range in firmware)
# ANA_INV_CURRENT_OFFSET = -51.38759228 (negative → inverted range in firmware)
# ANA_DC_BUS_VOLTAGE_OFFSET = 0.391120149 (positive)
# ANA_AC_IN_VOLTAGE_OFFSET = 0.639814597 (positive)
# ANA_COIL_VOLTAGE_OFFSET = -7.280746292 (negative → inverted range in firmware)
# ANA_PFC_CURRENT_RMS_OFFSET = 0.0 (excluded: trivial limits)
#
# For negative nominals: min_val = lo_limit = nominal×0.8 (less negative),
# max_val = up_limit = nominal×1.2 (more negative).
# Phase 1 writes a more-negative value (<min_val) → expect clamp to min_val.
# Phase 2 writes a less-negative value (>max_val) → expect clamp to max_val.
('PFC_current_cali_B', 14, 'float32', -0.375304, -0.562956, -0.65, -0.30),
('INV_current_cali_B', 18, 'float32', -41.11007, -61.66511, -70.0, -30.0),
('DC_bus_voltage_cali_B', 22, 'float32', 0.312896, 0.469344, 0.293, 0.490),
('AC_in_voltage_cali_B', 26, 'float32', 0.511852, 0.767778, 0.480, 0.800),
('Coil_voltage_cali_B', 82, 'float32', -5.824597, -8.736896, -10.0, -4.0),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _is_float(data_type: str) -> bool:
return data_type == 'float32'
def read_reg(instr: minimalmodbus.Instrument, addr: int, data_type: str):
if _is_float(data_type):
return instr.read_float(addr, functioncode=3, number_of_registers=2,
byteorder=minimalmodbus.BYTEORDER_BIG)
signed = (data_type == 'int16')
return instr.read_register(addr, number_of_decimals=0, functioncode=3, signed=signed)
def write_reg(instr: minimalmodbus.Instrument, addr: int, value, data_type: str) -> None:
if _is_float(data_type):
instr.write_float(addr, float(value), number_of_registers=2,
byteorder=minimalmodbus.BYTEORDER_BIG)
return
signed = (data_type == 'int16')
instr.write_register(addr, int(value), number_of_decimals=0, functioncode=6, signed=signed)
def _check_result(readback, expected, data_type: str) -> str:
if readback == 'ERR':
return 'ERR'
if _is_float(data_type):
if abs(expected) < 1e-9:
ok = abs(readback - expected) < FLOAT_EPSILON
else:
ok = abs(readback - expected) / abs(expected) < FLOAT_EPSILON
return 'PASS' if ok else 'FAIL'
return 'PASS' if readback == expected else 'FAIL'
def _wait_for_device(instr: minimalmodbus.Instrument, poll_addr: int) -> None:
deadline = time.monotonic() + REWRITE_WAIT
while time.monotonic() < deadline:
time.sleep(0.5)
try:
instr.read_register(poll_addr, number_of_decimals=0, functioncode=3, signed=False)
return
except minimalmodbus.ModbusException:
pass
print(" (trigger: device did not respond within REWRITE_WAIT — may not have triggered)")
def trigger_rewrite_rpu(instr: minimalmodbus.Instrument) -> None:
"""Write MAIN_FORCE_NORMAL_MODE password → modbus_to_config() + write_all_flash() + soft_reset()."""
instr.write_register(ADDR_PASSWORD_LO, PASSWORD_LO, 0, functioncode=6)
try:
instr.write_register(ADDR_PASSWORD_HI, PASSWORD_HI, 0, functioncode=6)
print(" (trigger: PASSWORD_HI ack received)")
except minimalmodbus.NoResponseError:
print(" (trigger: NoResponseError — device started flash write immediately)")
_wait_for_device(instr, ADDR_PASSWORD_LO)
def _wait_for_tpu_trigger_state(instr: minimalmodbus.Instrument) -> bool:
"""Poll input reg 61 (MODBUS_MAIN_STATE) until state allows write_flash trigger.
Returns True if state reached, False if timed out.
Required because firmware only processes write_flash in specific states.
"""
deadline = time.monotonic() + REWRITE_WAIT
while time.monotonic() < deadline:
try:
state = instr.read_register(ADDR_TPU_STATE, number_of_decimals=0,
functioncode=4, signed=False)
if state in TPU_TRIGGER_ALLOWED_STATES:
print(f" (trigger: TPU state=0x{state:02X} — ready to accept trigger)")
return True
print(f" (trigger: TPU state=0x{state:02X} — waiting for allowed state...)")
except minimalmodbus.ModbusException:
pass
time.sleep(0.5)
print(" (trigger: WARNING — TPU state never reached allowed state; trigger may be ignored)")
return False
def trigger_rewrite_tpu(instr: minimalmodbus.Instrument) -> None:
"""Write coil bit 49 (MODBUS_ALL_CFG_CMD_BIT) → modbus_to_config() + write_all_flash() + soft_reset()."""
_wait_for_tpu_trigger_state(instr)
try:
instr.write_bit(TPU_BIT_WRITE_FLASH, 1, functioncode=5)
print(" (trigger: coil bit write ack received)")
except minimalmodbus.NoResponseError:
print(" (trigger: NoResponseError — device started flash write immediately)")
_wait_for_device(instr, ADDR_HARDWARE_REV_HI)
def detect_device_type(instr: minimalmodbus.Instrument) -> str:
rev_hi = instr.read_register(ADDR_HARDWARE_REV_HI, number_of_decimals=0, functioncode=3, signed=False)
print(f" HARDWARE_REV_HI = 0x{rev_hi:04X} ({rev_hi})")
if rev_hi == HW_REV_TPU:
return 'TPU'
elif rev_hi == HW_REV_RPU:
return 'RPU'
else:
print(f" WARNING: unknown HW_REV_HI — defaulting to RPU")
return 'RPU'
# ---------------------------------------------------------------------------
# Main test logic
# ---------------------------------------------------------------------------
def run_tests(port: str) -> tuple[str, list[dict]]:
instr = minimalmodbus.Instrument(port=port, slaveaddress=SLAVE_ID, mode='rtu')
instr.serial.baudrate = BAUD
instr.serial.timeout = TIMEOUT
print(f"Connected to {port} slave=0x{SLAVE_ID:02X} ({SLAVE_ID}) baud={BAUD}")
# -------------------------------------------------------------------------
# Auto-detect device type
# -------------------------------------------------------------------------
print("\n[Detection] Reading HARDWARE_REV_HI...")
device_type = detect_device_type(instr)
if device_type == 'TPU':
TESTS = TPU_TESTS
trigger_fn = trigger_rewrite_tpu
time.sleep(5)
else:
TESTS = RPU_TESTS
trigger_fn = trigger_rewrite_rpu
time.sleep(5)
print(f" Device type: {device_type}{len(TESTS)} registers in test list")
# -------------------------------------------------------------------------
# Phase 0: Save all defaults
# -------------------------------------------------------------------------
print("\n[Phase 0] Saving default values...")
defaults: dict[int, any] = {}
for _, addr, data_type, *_ in TESTS:
if addr not in defaults:
try:
defaults[addr] = read_reg(instr, addr, data_type)
val = defaults[addr]
print(f" addr {addr:3d}: {val:.6g}" if _is_float(data_type) else f" addr {addr:3d}: {val}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d}: ERROR — {e}")
defaults[addr] = None
# -------------------------------------------------------------------------
# Phase 1: Write ALL below-min values → single rewrite → read all back
# -------------------------------------------------------------------------
print("\n[Phase 1] Writing all below-min values...")
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
if below_input is not None and defaults.get(addr) is not None:
try:
write_reg(instr, addr, below_input, data_type)
fmt = f"{below_input:.6g}" if _is_float(data_type) else str(below_input)
print(f" addr {addr:3d} ({label}): wrote {fmt}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d} ({label}): write ERROR — {e}")
print("\n[Phase 1] Triggering rewrite...")
trigger_fn(instr)
time.sleep(5)
print("[Phase 1] Reading back...")
below_readbacks: dict[int, any] = {}
for (_, addr, data_type, *_) in TESTS:
if addr not in below_readbacks:
try:
below_readbacks[addr] = read_reg(instr, addr, data_type)
except minimalmodbus.ModbusException:
below_readbacks[addr] = 'ERR'
# -------------------------------------------------------------------------
# Phase 2: Write ALL above-max values → single rewrite → read all back
# -------------------------------------------------------------------------
print("\n[Phase 2] Writing all above-max values...")
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
if above_input is not None and defaults.get(addr) is not None:
try:
write_reg(instr, addr, above_input, data_type)
fmt = f"{above_input:.6g}" if _is_float(data_type) else str(above_input)
print(f" addr {addr:3d} ({label}): wrote {fmt}")
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d} ({label}): write ERROR — {e}")
print("\n[Phase 2] Triggering rewrite...")
trigger_fn(instr)
time.sleep(5)
print("[Phase 2] Reading back...")
above_readbacks: dict[int, any] = {}
for (_, addr, data_type, *_) in TESTS:
if addr not in above_readbacks:
try:
above_readbacks[addr] = read_reg(instr, addr, data_type)
except minimalmodbus.ModbusException:
above_readbacks[addr] = 'ERR'
# -------------------------------------------------------------------------
# Restore: write all defaults → single rewrite
# -------------------------------------------------------------------------
print("\n[Restore] Writing all defaults...")
for (_, addr, data_type, *_) in TESTS:
default = defaults.get(addr)
if default is not None:
try:
write_reg(instr, addr, default, data_type)
except minimalmodbus.ModbusException as e:
print(f" addr {addr:3d}: restore write ERROR — {e}")
trigger_fn(instr)
time.sleep(5)
print("[Restore] Done.")
# -------------------------------------------------------------------------
# Compile results
# -------------------------------------------------------------------------
results = []
for (label, addr, data_type, min_val, max_val, below_input, above_input) in TESTS:
default = defaults.get(addr)
def _fmt(v):
if isinstance(v, float):
return f"{v:.6g}"
return v
row = {
'label': label,
'data_type': data_type,
'default': _fmt(default) if default is not None else 'ERR',
'min': _fmt(min_val),
'max': _fmt(max_val),
'below_input': 'N/A',
'below_readback': 'N/A',
'below_result': 'N/A',
'above_input': 'N/A',
'above_readback': 'N/A',
'above_result': 'N/A',
}
if below_input is not None and default is not None:
rb = below_readbacks.get(addr, 'ERR')
row['below_input'] = _fmt(below_input)
row['below_readback'] = _fmt(rb) if rb != 'ERR' else 'ERR'
row['below_result'] = _check_result(rb, min_val, data_type)
if above_input is not None and default is not None:
rb = above_readbacks.get(addr, 'ERR')
row['above_input'] = _fmt(above_input)
row['above_readback'] = _fmt(rb) if rb != 'ERR' else 'ERR'
row['above_result'] = _check_result(rb, max_val, data_type)
results.append(row)
print(f"\n{''*60}")
print("All tests complete.")
return device_type, results
# ---------------------------------------------------------------------------
# CSV report
# ---------------------------------------------------------------------------
def write_csv(device_type: str, results: list[dict]) -> None:
import datetime
script_dir = os.path.dirname(os.path.abspath(__file__))
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
prefix = device_type.lower()
csv_path = os.path.join(script_dir, f'{prefix}_modbus_limiter_report_{ts}.csv')
with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f)
writer.writerow([
'Register',
'Data type',
'Default',
'Min',
'Max',
'Below-min input',
'Readback (below-min)',
'Below-min result',
'Above-max input',
'Readback (above-max)',
'Above-max result',
])
for row in results:
writer.writerow([
row['label'],
row['data_type'],
row['default'],
row['min'],
row['max'],
row['below_input'],
row['below_readback'],
row['below_result'],
row['above_input'],
row['above_readback'],
row['above_result'],
])
total = pass_count = fail_count = na_count = err_count = 0
for row in results:
for key in ('below_result', 'above_result'):
v = row[key]
if v == 'N/A':
na_count += 1
continue
total += 1
if v == 'PASS':
pass_count += 1
elif v == 'FAIL':
fail_count += 1
else:
err_count += 1
print(f"\nReport saved: {csv_path}")
print(f"Results — PASS: {pass_count} FAIL: {fail_count} ERROR: {err_count} N/A: {na_count} (total tests: {total})")
# ---------------------------------------------------------------------------
if __name__ == '__main__':
port_num = input("Enter COM port number (e.g. 73 for COM73): ").strip()
port = f"COM{port_num}"
print(f"Using port: {port}")
device_type, results = run_tests(port)
if results:
write_csv(device_type, results)