Skip to content

Instantly share code, notes, and snippets.

@cardil
Last active March 27, 2026 01:19
Show Gist options
  • Select an option

  • Save cardil/1551323387f39a5a311229a7793c870a to your computer and use it in GitHub Desktop.

Select an option

Save cardil/1551323387f39a5a311229a7793c870a to your computer and use it in GitHub Desktop.
NUT apc_modbus word-order bug proof — source bit hypothesis test (disproved)
#!/usr/bin/env python3
"""Bug 4 — Test: Does setting SOURCE_RJ45_PORT bit fix OutletCommand writes?
This script bypasses the NUT driver and sends a raw Modbus write to register
1538 (OutletCommand_BF) with the SOURCE_RJ45_PORT bit (bit 14) set.
Command: load.on targeting MOG (Main Outlet Group)
- Without source bit: 0x0102 = CMD_OUTPUT_ON | TARGET_MOG
- With source bit: 0x4102 = CMD_OUTPUT_ON | TARGET_MOG | SOURCE_RJ45_PORT
This is safe because load is already on — load.on is a no-op.
MUST stop nut-driver@apc first (it holds /dev/ttyS0).
"""
import sys
import time
try:
from pymodbus.client import ModbusSerialClient
except ImportError:
try:
from pymodbus.client.sync import ModbusSerialClient
except ImportError:
print("ERROR: pymodbus not installed. Run: pip3 install pymodbus")
sys.exit(1)
SERIAL_PORT = "/dev/ttyS0"
BAUDRATE = 9600
SLAVE_ID = 1
# Register 1538 = OutletCommand_BF (2 registers = 32 bits)
REG_ADDR = 1538
REG_COUNT = 2
# Bit definitions from apc_modbus.h
CMD_OUTPUT_ON = (1 << 1) # bit 1
TARGET_MOG = (1 << 8) # bit 8 — Main Outlet Group
SOURCE_RJ45_PORT = (1 << 14) # bit 14
def test_write(client, description, value_32bit):
"""Write a 32-bit value to register 1538 as two 16-bit registers."""
reg_hi = (value_32bit >> 16) & 0xFFFF
reg_lo = value_32bit & 0xFFFF
print(f"\n--- {description} ---")
print(f" Value: 0x{value_32bit:08X} (registers: [0x{reg_lo:04X}, 0x{reg_hi:04X}])")
result = client.write_registers(REG_ADDR, [reg_lo, reg_hi], slave=SLAVE_ID)
if result.isError():
print(f" RESULT: ❌ FAIL — {result}")
return False
else:
print(f" RESULT: ✅ SUCCESS")
return True
def main():
print("Bug 4 — Testing SOURCE bit hypothesis on register 1538")
print(f"Port: {SERIAL_PORT}, Baud: {BAUDRATE}, Slave: {SLAVE_ID}")
client = ModbusSerialClient(
port=SERIAL_PORT,
baudrate=BAUDRATE,
parity='N',
stopbits=1,
bytesize=8,
timeout=3,
)
if not client.connect():
print("ERROR: Cannot connect to serial port")
sys.exit(1)
print("Connected to serial port")
time.sleep(0.5) # Let the connection settle
# Test 1: load.on WITHOUT source bit (same as NUT driver — should FAIL)
val_no_source = CMD_OUTPUT_ON | TARGET_MOG
r1 = test_write(client, "load.on WITHOUT SOURCE bit (expect FAIL)", val_no_source)
time.sleep(1)
# Test 2: load.on WITH SOURCE_RJ45_PORT bit (hypothesis: should SUCCEED)
val_with_source = CMD_OUTPUT_ON | TARGET_MOG | SOURCE_RJ45_PORT
r2 = test_write(client, "load.on WITH SOURCE_RJ45_PORT bit (hypothesis: SUCCEED)", val_with_source)
time.sleep(1)
# Test 3: If RJ45 didn't work, try USB source bit
if not r2:
val_with_usb = CMD_OUTPUT_ON | TARGET_MOG | (1 << 12) # SOURCE_USB_PORT
r3 = test_write(client, "load.on WITH SOURCE_USB_PORT bit (fallback)", val_with_usb)
time.sleep(1)
# Test 4: Try LOCAL_USER source bit
if not r3:
val_with_local = CMD_OUTPUT_ON | TARGET_MOG | (1 << 13) # SOURCE_LOCAL_USER
test_write(client, "load.on WITH SOURCE_LOCAL_USER bit (fallback 2)", val_with_local)
client.close()
print("\n=== SUMMARY ===")
print(f"Without SOURCE bit: {'PASS ✅' if r1 else 'FAIL ❌'}")
print(f"With SOURCE_RJ45: {'PASS ✅' if r2 else 'FAIL ❌'}")
if r2 and not r1:
print("\n🎯 CONFIRMED: Missing SOURCE_RJ45_PORT bit is the root cause!")
print(" Fix: Add SOURCE_RJ45_PORT to all OutletCommand writes in apc_modbus.c")
elif r1:
print("\n🤔 Both passed — issue may be NUT-specific (framing, timing, etc.)")
else:
print("\n🔍 Neither passed — source bit is NOT the fix; investigate further")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment