336 lines
19 KiB
Python
Executable File
336 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import argparse
|
|
import threading
|
|
import time
|
|
import sys
|
|
import curses
|
|
import os
|
|
from pymodbus.client import ModbusSerialClient
|
|
from pymodbus.exceptions import ModbusException
|
|
|
|
# --- Register Definitions ---
|
|
# (omitted for brevity, no changes here)
|
|
|
|
|
|
|
|
# --- Global State ---
|
|
stop_event = threading.Event()
|
|
client = None
|
|
status_data = {}
|
|
status_lock = threading.Lock()
|
|
update_status = {"running": False, "message": "", "progress": 0.0}
|
|
update_lock = threading.Lock()
|
|
|
|
def format_uptime(seconds):
|
|
if not isinstance(seconds, (int, float)) or seconds < 0: return "N/A"
|
|
if seconds == 0: return "0s"
|
|
days, rem = divmod(seconds, 86400); hours, rem = divmod(rem, 3600); minutes, secs = divmod(rem, 60)
|
|
parts = []
|
|
if days > 0: parts.append(f"{int(days)}d")
|
|
if hours > 0: parts.append(f"{int(hours)}h")
|
|
if minutes > 0: parts.append(f"{int(minutes)}m")
|
|
if secs > 0 or not parts: parts.append(f"{int(secs)}s")
|
|
return " ".join(parts)
|
|
|
|
def poll_status(slave_id, interval):
|
|
global status_data
|
|
reconnect_attempts = 0
|
|
max_reconnect_attempts = 5
|
|
reconnect_delay = 1 # seconds
|
|
|
|
while not stop_event.is_set():
|
|
if update_status["running"]:
|
|
time.sleep(interval)
|
|
continue
|
|
|
|
new_data = {}
|
|
try:
|
|
if not client.is_socket_open():
|
|
reconnect_attempts += 1
|
|
if reconnect_attempts >= max_reconnect_attempts:
|
|
new_data["error"] = f"Failed to reconnect after {max_reconnect_attempts} attempts. Exiting."
|
|
stop_event.set()
|
|
break
|
|
|
|
# Attempt to connect
|
|
if client.connect():
|
|
reconnect_attempts = 0
|
|
with status_lock:
|
|
status_data["error"] = None # Clear error in status_data immediately
|
|
time.sleep(0.1) # Allow UI to refresh with cleared error
|
|
else:
|
|
new_data["error"] = f"Connection lost. Attempting to reconnect ({reconnect_attempts}/{max_reconnect_attempts})..."
|
|
time.sleep(reconnect_delay)
|
|
continue
|
|
|
|
# If connected, try to read data
|
|
ir_valve = client.read_input_registers(REG_INPUT_VALVE_STATE_MOVEMENT, count=1, slave=slave_id)
|
|
ir_current = client.read_input_registers(REG_INPUT_MOTOR_OPEN_CURRENT_MA, count=2, slave=slave_id)
|
|
ir_dig = client.read_input_registers(REG_INPUT_DIGITAL_INPUTS_STATE, count=2, slave=slave_id)
|
|
ir_sys = client.read_input_registers(REG_INPUT_FIRMWARE_VERSION_MAJOR_MINOR, count=6, slave=slave_id)
|
|
hr_valve = client.read_holding_registers(REG_HOLDING_MAX_OPENING_TIME_S, count=6, slave=slave_id)
|
|
hr_dig = client.read_holding_registers(REG_HOLDING_DIGITAL_OUTPUTS_STATE, count=1, slave=slave_id)
|
|
hr_sys = client.read_holding_registers(REG_HOLDING_WATCHDOG_TIMEOUT_S, count=1, slave=slave_id)
|
|
|
|
for res in [ir_valve, ir_current, ir_dig, ir_sys, hr_valve, hr_dig, hr_sys]:
|
|
if res.isError():
|
|
raise ModbusException(str(res))
|
|
|
|
valve_state_raw = ir_valve.registers[0]
|
|
movement_map = {0: "Idle", 1: "Opening", 2: "Closing", 3: "Error"}
|
|
state_map = {0: "Closed", 1: "Open"}
|
|
new_data["movement"] = movement_map.get(valve_state_raw >> 8, 'Unknown')
|
|
new_data["state"] = state_map.get(valve_state_raw & 0xFF, 'Unknown')
|
|
new_data["motor_current_open"] = f"{ir_current.registers[0]} mA"
|
|
new_data["motor_current_close"] = f"{ir_current.registers[1]} mA"
|
|
new_data["open_time"] = f"{hr_valve.registers[0]}s"
|
|
new_data["close_time"] = f"{hr_valve.registers[1]}s"
|
|
new_data["end_curr_open"] = f"{hr_valve.registers[2]}mA"
|
|
new_data["end_curr_close"] = f"{hr_valve.registers[3]}mA"
|
|
new_data["obstacle_open"] = f"{hr_valve.registers[4]}mA"
|
|
new_data["obstacle_close"] = f"{hr_valve.registers[5]}mA"
|
|
new_data["digital_inputs"] = f"0x{ir_dig.registers[0]:04X}"
|
|
new_data["button_events"] = f"0x{ir_dig.registers[1]:04X}"
|
|
new_data["digital_outputs"] = f"0x{hr_dig.registers[0]:04X}"
|
|
|
|
fw_major = ir_sys.registers[0] >> 8
|
|
fw_minor = ir_sys.registers[0] & 0xFF
|
|
fw_patch = ir_sys.registers[1]
|
|
uptime_seconds = (ir_sys.registers[4] << 16) | ir_sys.registers[3]
|
|
supply_voltage_mv = ir_sys.registers[5]
|
|
new_data["firmware"] = f"v{fw_major}.{fw_minor}.{fw_patch}"
|
|
new_data["device_status"] = "OK" if ir_sys.registers[2] == 0 else "ERROR"
|
|
new_data["uptime"] = format_uptime(uptime_seconds)
|
|
new_data["supply_voltage"] = f"{supply_voltage_mv / 1000.0:.2f} V"
|
|
new_data["watchdog"] = f"{hr_sys.registers[0]}s"
|
|
new_data["error"] = None # Clear any previous error on successful read
|
|
reconnect_attempts = 0 # Reset attempts on successful communication
|
|
except Exception as e:
|
|
new_data["error"] = f"Communication Error: {e}. Closing connection."
|
|
client.close() # Close connection to force reconnect attempt in next loop
|
|
finally:
|
|
with status_lock:
|
|
status_data = new_data
|
|
time.sleep(interval)
|
|
|
|
def firmware_update_thread(slave_id, filepath):
|
|
global update_status
|
|
with update_lock:
|
|
update_status = {"running": True, "message": "Starting update...", "progress": 0.0}
|
|
try:
|
|
with open(filepath, 'rb') as f: firmware = f.read()
|
|
file_size = len(firmware)
|
|
chunk_size = 248
|
|
offset = 0
|
|
while offset < file_size:
|
|
chunk = firmware[offset:offset + chunk_size]
|
|
with update_lock:
|
|
update_status["message"] = f"Sending chunk {offset//chunk_size + 1}/{(file_size + chunk_size - 1)//chunk_size}..."
|
|
update_status["progress"] = offset / file_size
|
|
client.write_register(REG_HOLDING_FWU_CHUNK_OFFSET_LOW, offset & 0xFFFF, slave=slave_id)
|
|
client.write_register(REG_HOLDING_FWU_CHUNK_OFFSET_HIGH, (offset >> 16) & 0xFFFF, slave=slave_id)
|
|
client.write_register(REG_HOLDING_FWU_CHUNK_SIZE, len(chunk), slave=slave_id)
|
|
padded_chunk = chunk + (b'\x00' if len(chunk) % 2 != 0 else b'')
|
|
registers = [int.from_bytes(padded_chunk[i:i+2], 'big') for i in range(0, len(padded_chunk), 2)]
|
|
burst_size_regs = 16
|
|
for i in range(0, len(registers), burst_size_regs):
|
|
reg_burst = registers[i:i + burst_size_regs]
|
|
start_addr = REG_HOLDING_FWU_DATA_BUFFER + i
|
|
client.write_registers(start_addr, reg_burst, slave=slave_id)
|
|
time.sleep(0.02)
|
|
time.sleep(0.1)
|
|
client.read_input_registers(REG_INPUT_FWU_LAST_CHUNK_CRC, count=1, slave=slave_id)
|
|
client.write_register(REG_HOLDING_FWU_COMMAND, 1, slave=slave_id)
|
|
offset += len(chunk)
|
|
with update_lock:
|
|
update_status["progress"] = 1.0
|
|
update_status["message"] = "Finalizing update..."
|
|
client.write_register(REG_HOLDING_FWU_COMMAND, 2, slave=slave_id)
|
|
time.sleep(1)
|
|
with update_lock: update_status["message"] = "Update complete! Slave is rebooting."
|
|
time.sleep(2)
|
|
except Exception as e:
|
|
with update_lock: update_status["message"] = f"Error: {e}"
|
|
time.sleep(3)
|
|
finally:
|
|
with update_lock: update_status["running"] = False
|
|
|
|
def draw_button(stdscr, y, x, text, selected=False):
|
|
"""Draws a button, handling selection highlight."""
|
|
color = curses.color_pair(2) if selected else curses.color_pair(1)
|
|
button_width = len(text) + 2
|
|
stdscr.addstr(y, x, " " * button_width, color)
|
|
stdscr.addstr(y, x + 1, text, color)
|
|
|
|
def file_browser(stdscr):
|
|
"""A simple curses file browser."""
|
|
curses.curs_set(1)
|
|
path = os.getcwd()
|
|
selected_index = 0
|
|
|
|
while True:
|
|
stdscr.erase()
|
|
h, w = stdscr.getmaxyx()
|
|
stdscr.addstr(0, 0, f"Select Firmware File: {path}".ljust(w-1), curses.color_pair(2))
|
|
|
|
try:
|
|
items = sorted(os.listdir(path))
|
|
except OSError as e:
|
|
items = [f".. (Error: {e})"]
|
|
|
|
items.insert(0, "..")
|
|
|
|
for i, item_name in enumerate(items):
|
|
if i >= h - 2: break
|
|
display_name = item_name
|
|
if os.path.isdir(os.path.join(path, item_name)):
|
|
display_name += "/"
|
|
|
|
if i == selected_index:
|
|
stdscr.addstr(i + 1, 0, display_name, curses.color_pair(2))
|
|
else:
|
|
stdscr.addstr(i + 1, 0, display_name)
|
|
|
|
key = stdscr.getch()
|
|
|
|
if key == curses.KEY_UP:
|
|
selected_index = max(0, selected_index - 1)
|
|
elif key == curses.KEY_DOWN:
|
|
selected_index = min(len(items) - 1, selected_index + 1)
|
|
elif key == curses.KEY_ENTER or key in [10, 13]:
|
|
selected_item_path = os.path.join(path, items[selected_index])
|
|
if os.path.isdir(selected_item_path):
|
|
path = os.path.abspath(selected_item_path)
|
|
selected_index = 0
|
|
else:
|
|
return selected_item_path
|
|
elif key == 27: # ESC key
|
|
return None
|
|
|
|
def main_menu(stdscr, slave_id):
|
|
global status_data, update_status
|
|
curses.curs_set(0); stdscr.nodelay(1); stdscr.timeout(100)
|
|
curses.start_color(); curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE); curses.init_pair(2, curses.COLOR_BLUE, curses.COLOR_WHITE); curses.init_pair(3, curses.COLOR_RED, curses.COLOR_BLUE)
|
|
stdscr.bkgd(' ', curses.color_pair(1))
|
|
|
|
menu = ["Open Valve", "Close Valve", "Stop Valve", "Settings", "Reset Node", "Firmware Update", "Exit"]
|
|
settings_menu = ["Set Max Open Time", "Set Max Close Time", "Set End Current Open", "Set End Current Close", "Set Obstacle Open", "Set Obstacle Close", "Set Watchdog", "Back"]
|
|
current_menu = menu
|
|
current_row_idx = 0
|
|
message, message_time = "", 0
|
|
input_mode, input_prompt, input_str, input_target_reg = False, "", "", 0
|
|
|
|
while not stop_event.is_set():
|
|
h, w = stdscr.getmaxyx()
|
|
key = stdscr.getch()
|
|
|
|
with update_lock: is_updating = update_status["running"]
|
|
|
|
if is_updating:
|
|
pass
|
|
elif input_mode:
|
|
if key in [10, 13]:
|
|
try:
|
|
value = int(input_str)
|
|
client.write_register(input_target_reg, value, slave=slave_id)
|
|
message = f"-> Set register 0x{input_target_reg:04X} to {value}"
|
|
except Exception as e: message = f"-> Error: {e}"
|
|
message_time, input_mode, input_str = time.time(), False, ""
|
|
elif key == curses.KEY_BACKSPACE or key == 127: input_str = input_str[:-1]
|
|
elif key != -1 and chr(key).isprintable(): input_str += chr(key)
|
|
else:
|
|
if key == curses.KEY_UP: current_row_idx = (current_row_idx - 1) % len(current_menu)
|
|
elif key == curses.KEY_DOWN: current_row_idx = (current_row_idx + 1) % len(current_menu)
|
|
elif key == curses.KEY_ENTER or key in [10, 13]:
|
|
selected_option = current_menu[current_row_idx]
|
|
message_time = time.time()
|
|
if selected_option == "Exit": stop_event.set(); continue
|
|
elif selected_option == "Back": current_menu = menu; current_row_idx = 0; continue
|
|
elif selected_option == "Settings": current_menu = settings_menu; current_row_idx = 0; continue
|
|
elif selected_option == "Open Valve": client.write_register(REG_HOLDING_VALVE_COMMAND, 1, slave=slave_id); message = "-> Sent OPEN command"
|
|
elif selected_option == "Close Valve": client.write_register(REG_HOLDING_VALVE_COMMAND, 2, slave=slave_id); message = "-> Sent CLOSE command"
|
|
elif selected_option == "Stop Valve": client.write_register(REG_HOLDING_VALVE_COMMAND, 0, slave=slave_id); message = "-> Sent STOP command"
|
|
elif selected_option == "Set Max Open Time":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter Max Open Time (s): ", REG_HOLDING_MAX_OPENING_TIME_S
|
|
elif selected_option == "Set Max Close Time":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter Max Close Time (s): ", REG_HOLDING_MAX_CLOSING_TIME_S
|
|
elif selected_option == "Set End Current Open":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter End Current Threshold Open (mA): ", REG_HOLDING_END_CURRENT_THRESHOLD_OPEN_MA
|
|
elif selected_option == "Set End Current Close":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter End Current Threshold Close (mA): ", REG_HOLDING_END_CURRENT_THRESHOLD_CLOSE_MA
|
|
elif selected_option == "Set Watchdog":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter Watchdog Timeout (s): ", REG_HOLDING_WATCHDOG_TIMEOUT_S
|
|
elif selected_option == "Set Obstacle Open":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter Obstacle Threshold Open (mA): ", REG_HOLDING_OBSTACLE_THRESHOLD_OPEN_MA
|
|
elif selected_option == "Set Obstacle Close":
|
|
input_mode, input_prompt, input_target_reg = True, "Enter Obstacle Threshold Close (mA): ", REG_HOLDING_OBSTACLE_THRESHOLD_CLOSE_MA
|
|
elif selected_option == "Reset Node":
|
|
try:
|
|
client.write_register(REG_HOLDING_DEVICE_RESET, 1, slave=slave_id)
|
|
message = "-> Sent RESET command. Node should reboot."
|
|
except Exception as e:
|
|
message = f"-> Error sending reset: {e}"
|
|
elif selected_option == "Firmware Update":
|
|
filepath = file_browser(stdscr)
|
|
if filepath:
|
|
threading.Thread(target=firmware_update_thread, args=(slave_id, filepath), daemon=True).start()
|
|
else:
|
|
message = "-> Firmware update cancelled."
|
|
|
|
stdscr.erase()
|
|
if is_updating:
|
|
with update_lock: prog, msg = update_status["progress"], update_status["message"]
|
|
stdscr.addstr(h // 2 - 1, w // 2 - 25, "FIRMWARE UPDATE IN PROGRESS", curses.A_BOLD | curses.color_pair(2))
|
|
stdscr.addstr(h // 2, w // 2 - 25, f"[{'#' * int(prog * 50):<50}] {prog:.0%}")
|
|
stdscr.addstr(h // 2 + 1, w // 2 - 25, msg.ljust(50))
|
|
else:
|
|
with status_lock: current_data = status_data.copy()
|
|
bold, normal = curses.color_pair(1) | curses.A_BOLD, curses.color_pair(1)
|
|
if current_data.get("error"): stdscr.addstr(0, 0, current_data["error"], curses.color_pair(3) | curses.A_BOLD)
|
|
else:
|
|
col1, col2, col3, col4 = 2, 30, 58, 88
|
|
stdscr.addstr(1, col1, "State:", bold); stdscr.addstr(1, col1 + 18, str(current_data.get('state', 'N/A')), normal)
|
|
stdscr.addstr(2, col1, "Movement:", bold); stdscr.addstr(2, col1 + 18, str(current_data.get('movement', 'N/A')), normal)
|
|
stdscr.addstr(3, col1, "Open Current:", bold); stdscr.addstr(3, col1 + 18, str(current_data.get('motor_current_open', 'N/A')), normal)
|
|
stdscr.addstr(4, col1, "Close Current:", bold); stdscr.addstr(4, col1 + 18, str(current_data.get('motor_current_close', 'N/A')), normal)
|
|
stdscr.addstr(1, col2, "Digital Inputs:", bold); stdscr.addstr(1, col2 + 18, str(current_data.get('digital_inputs', 'N/A')), normal)
|
|
stdscr.addstr(2, col2, "Digital Outputs:", bold); stdscr.addstr(2, col2 + 18, str(current_data.get('digital_outputs', 'N/A')), normal)
|
|
stdscr.addstr(3, col2, "Button Events:", bold); stdscr.addstr(3, col2 + 18, str(current_data.get('button_events', 'N/A')), normal)
|
|
stdscr.addstr(1, col3, "Max Open Time:", bold); stdscr.addstr(1, col3 + 16, str(current_data.get('open_time', 'N/A')), normal)
|
|
stdscr.addstr(2, col3, "Max Close Time:", bold); stdscr.addstr(2, col3 + 16, str(current_data.get('close_time', 'N/A')), normal)
|
|
stdscr.addstr(3, col3, "Watchdog:", bold); stdscr.addstr(3, col3 + 16, str(current_data.get('watchdog', 'N/A')), normal)
|
|
stdscr.addstr(4, col3, "End Curr Open:", bold); stdscr.addstr(4, col3 + 16, str(current_data.get('end_curr_open', 'N/A')), normal)
|
|
stdscr.addstr(5, col3, "End Curr Close:", bold); stdscr.addstr(5, col3 + 16, str(current_data.get('end_curr_close', 'N/A')), normal)
|
|
stdscr.addstr(6, col3, "Obstacle Open:", bold); stdscr.addstr(6, col3 + 16, str(current_data.get('obstacle_open', 'N/A')), normal)
|
|
stdscr.addstr(7, col3, "Obstacle Close:", bold); stdscr.addstr(7, col3 + 16, str(current_data.get('obstacle_close', 'N/A')), normal)
|
|
stdscr.addstr(1, col4, "Firmware:", bold); stdscr.addstr(1, col4 + 14, str(current_data.get('firmware', 'N/A')), normal)
|
|
stdscr.addstr(2, col4, "Uptime:", bold); stdscr.addstr(2, col4 + 14, str(current_data.get('uptime', 'N/A')), normal)
|
|
stdscr.addstr(3, col4, "Dev. Status:", bold); stdscr.addstr(3, col4 + 14, str(current_data.get('device_status', 'N/A')), normal)
|
|
stdscr.addstr(4, col4, "Supply V:", bold); stdscr.addstr(4, col4 + 14, str(current_data.get('supply_voltage', 'N/A')), normal)
|
|
stdscr.addstr(6, 0, "─" * (w - 1), normal)
|
|
for idx, row in enumerate(current_menu):
|
|
draw_button(stdscr, 7 + (idx * 2), w // 2 - len(row) // 2, row, idx == current_row_idx)
|
|
if time.time() - message_time < 2.0: stdscr.addstr(h - 2, 0, message.ljust(w - 1), curses.color_pair(1) | curses.A_BOLD)
|
|
if input_mode:
|
|
curses.curs_set(1); stdscr.addstr(h - 2, 0, (input_prompt + input_str).ljust(w-1), curses.color_pair(2)); stdscr.move(h - 2, len(input_prompt) + len(input_str))
|
|
else: curses.curs_set(0)
|
|
curses.doupdate()
|
|
|
|
def main():
|
|
global client
|
|
parser = argparse.ArgumentParser(description="Modbus tool for irrigation system nodes.")
|
|
parser.add_argument("port", help="Serial port"); parser.add_argument("--baud", type=int, default=19200); parser.add_argument("--slave-id", type=int, default=1); parser.add_argument("--interval", type=float, default=1.0)
|
|
args = parser.parse_args()
|
|
client = ModbusSerialClient(port=args.port, baudrate=args.baud, stopbits=1, bytesize=8, parity="N", timeout=1)
|
|
if not client.connect(): print(f"Error: Failed to connect to serial port {args.port}"); sys.exit(1)
|
|
print("Successfully connected. Starting UI..."); time.sleep(0.5)
|
|
threading.Thread(target=poll_status, args=(args.slave_id, args.interval), daemon=True).start()
|
|
try: curses.wrapper(main_menu, args.slave_id)
|
|
finally:
|
|
stop_event.set()
|
|
print("\nExiting...")
|
|
if client.is_socket_open(): client.close()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|