Created
March 27, 2026 11:16
-
-
Save cardil/0e7fccfb7f989a444533b37fd5b8a58e to your computer and use it in GitHub Desktop.
Bug 4 — Modbus/TCP word order test via APC NMC (NUT #2338)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Test: Modbus/TCP word order via APC NMC. | |
| Reads raw register values to verify read word order, then tests write | |
| word order for OutletCommand (load.on) in both big-endian and little-endian. | |
| Safe: load.on while already on is a NOOP. | |
| Usage: | |
| python3 bug4-test-modbus-tcp.py <NMC_HOST> [NMC_PORT] | |
| Related: https://github.com/networkupstools/nut/issues/2338 | |
| """ | |
| import sys | |
| import time | |
| try: | |
| from pymodbus.client import ModbusTcpClient | |
| except ImportError: | |
| from pymodbus.client.sync import ModbusTcpClient | |
| NMC_HOST = None # set via CLI argument | |
| NMC_PORT = 502 | |
| SLAVE_ID = 1 | |
| # Register addresses (from apc_modbus.c) | |
| REG_DELAY_SHUTDOWN = 1029 # ups.delay.shutdown (1 reg, INT, RW) | |
| REG_DELAY_START = 1030 # ups.delay.start (1 reg, INT, RW) | |
| REG_DELAY_REBOOT = 1031 # ups.delay.reboot (2 regs, INT, RW) | |
| REG_OUTLET_CMD = 1538 # OutletCommand_BF (2 regs, BF) | |
| REG_UPS_CMD = 1536 # UPSCommand_BF (2 regs, BF) | |
| REG_TIMER_SHUTDOWN = 155 # ups.timer.shutdown (1 reg, read-only) | |
| # OutletCommand bits | |
| CMD_TURN_ON = 1 << 1 | |
| TARGET_MOG = 1 << 8 | |
| def read_regs(client, name, addr, count): | |
| """Read registers and print raw values.""" | |
| result = client.read_holding_registers(addr, count=count, slave=SLAVE_ID) | |
| if result.isError(): | |
| print(f" {name}: ERROR reading reg {addr}: {result}") | |
| return None | |
| vals = result.registers | |
| hex_vals = [f"0x{v:04x}" for v in vals] | |
| print(f" {name} (reg {addr}-{addr+count-1}): {hex_vals} = raw [{', '.join(str(v) for v in vals)}]") | |
| return vals | |
| def write_cmd(client, desc, addr, regs): | |
| """Write registers and report result.""" | |
| hex_vals = [f"0x{v:04x}" for v in regs] | |
| print(f" Writing {desc}: reg {addr}={hex_vals}") | |
| result = client.write_registers(addr, values=regs, slave=SLAVE_ID) | |
| if result.isError(): | |
| print(f" RESULT: ❌ FAIL — {result}") | |
| return False | |
| print(f" RESULT: ✅ Modbus write accepted") | |
| return True | |
| def main(): | |
| global NMC_HOST, NMC_PORT | |
| if len(sys.argv) < 2: | |
| print(f"Usage: {sys.argv[0]} <NMC_HOST> [NMC_PORT]", file=sys.stderr) | |
| sys.exit(2) | |
| NMC_HOST = sys.argv[1] | |
| if len(sys.argv) >= 3: | |
| NMC_PORT = int(sys.argv[2]) | |
| print("=" * 60) | |
| print("Bug 4 — Modbus/TCP word order test via NMC") | |
| print(f"Target: {NMC_HOST}:{NMC_PORT}") | |
| print("=" * 60) | |
| client = ModbusTcpClient(host=NMC_HOST, port=NMC_PORT, timeout=5) | |
| if not client.connect(): | |
| print(f"ERROR: Cannot connect to {NMC_HOST}:{NMC_PORT}") | |
| sys.exit(1) | |
| print(f"\nConnected to {NMC_HOST}:{NMC_PORT}") | |
| time.sleep(0.3) | |
| # --- Step 1: Read raw register values --- | |
| print(f"\n--- Step 1: Read raw registers (verify read word order) ---") | |
| time.sleep(0.3) | |
| # 1-register values (word order irrelevant) | |
| read_regs(client, "ups.delay.shutdown", REG_DELAY_SHUTDOWN, 1) | |
| read_regs(client, "ups.delay.start", REG_DELAY_START, 1) | |
| # 2-register value — THIS is the key test | |
| regs = read_regs(client, "ups.delay.reboot", REG_DELAY_REBOOT, 2) | |
| if regs: | |
| be_val = (regs[0] << 16) | regs[1] # big-endian interpretation | |
| le_val = (regs[1] << 16) | regs[0] # little-endian interpretation | |
| print(f" → Big-endian interpretation: {be_val}") | |
| print(f" → Little-endian interpretation: {le_val}") | |
| print(f" → NUT reports: 8 (expected)") | |
| if be_val == 8: | |
| print(f" → Reads match BIG-endian (spec-compliant)") | |
| elif le_val == 8: | |
| print(f" → Reads match LITTLE-endian (non-spec)") | |
| else: | |
| print(f" → Neither interpretation gives 8 — unexpected!") | |
| # Read timer too | |
| read_regs(client, "ups.timer.shutdown", REG_TIMER_SHUTDOWN, 1) | |
| # --- Step 2: Test write word order with load.on (NOOP when already on) --- | |
| print(f"\n--- Step 2: Test load.on write (NOOP — load already on) ---") | |
| cmd_load_on = CMD_TURN_ON | TARGET_MOG # 0x0102 | |
| print(f" Command value: {hex(cmd_load_on)}") | |
| time.sleep(0.3) | |
| print(f"\n Test A: Little-endian [LSW, MSW] = [{hex(cmd_load_on)}, 0x0000]") | |
| time.sleep(0.3) | |
| le_ok = write_cmd(client, "load.on (LE)", REG_OUTLET_CMD, [cmd_load_on, 0x0000]) | |
| time.sleep(1) | |
| print(f"\n Test B: Big-endian [MSW, LSW] = [0x0000, {hex(cmd_load_on)}]") | |
| time.sleep(0.3) | |
| be_ok = write_cmd(client, "load.on (BE)", REG_OUTLET_CMD, [0x0000, cmd_load_on]) | |
| client.close() | |
| # --- Summary --- | |
| print(f"\n{'=' * 60}") | |
| print(f"SUMMARY") | |
| print(f"{'=' * 60}") | |
| print(f" Read word order (ups.delay.reboot): see above") | |
| print(f" Little-endian write (load.on): {'✅ accepted' if le_ok else '❌ rejected'}") | |
| print(f" Big-endian write (load.on): {'✅ accepted' if be_ok else '❌ rejected'}") | |
| if le_ok and not be_ok: | |
| print(f"\n → Modbus/TCP via NMC: SAME as serial — little-endian works, big-endian fails") | |
| print(f" → NMC does NOT translate word order — firmware bug confirmed") | |
| elif le_ok and be_ok: | |
| print(f"\n → Modbus/TCP via NMC: BOTH accepted — NMC may normalise, or firmware tolerant via TCP") | |
| elif not le_ok and be_ok: | |
| print(f"\n → Modbus/TCP via NMC: BIG-endian works — opposite of serial! NMC may translate") | |
| else: | |
| print(f"\n → Both failed — communication issue or different Modbus TCP setup needed") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment