Skip to content

Instantly share code, notes, and snippets.

@cardil
Created March 27, 2026 11:16
Show Gist options
  • Select an option

  • Save cardil/0e7fccfb7f989a444533b37fd5b8a58e to your computer and use it in GitHub Desktop.

Select an option

Save cardil/0e7fccfb7f989a444533b37fd5b8a58e to your computer and use it in GitHub Desktop.
Bug 4 — Modbus/TCP word order test via APC NMC (NUT #2338)
#!/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