#!/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) REG_INPUT_VALVE_STATE_MOVEMENT = 0x0000 REG_INPUT_MOTOR_OPEN_CURRENT_MA = 0x0001 REG_INPUT_MOTOR_CLOSE_CURRENT_MA = 0x0002 REG_INPUT_DIGITAL_INPUTS_STATE = 0x0020 REG_INPUT_BUTTON_EVENTS = 0x0021 REG_INPUT_FIRMWARE_VERSION_MAJOR_MINOR = 0x00F0 REG_INPUT_FIRMWARE_VERSION_PATCH = 0x00F1 REG_INPUT_DEVICE_STATUS = 0x00F2 REG_INPUT_UPTIME_SECONDS_LOW = 0x00F3 REG_INPUT_UPTIME_SECONDS_HIGH = 0x00F4 REG_INPUT_SUPPLY_VOLTAGE_MV = 0x00F5 REG_INPUT_FWU_LAST_CHUNK_CRC = 0x0100 REG_HOLDING_VALVE_COMMAND = 0x0000 REG_HOLDING_MAX_OPENING_TIME_S = 0x0001 REG_HOLDING_MAX_CLOSING_TIME_S = 0x0002 REG_HOLDING_END_CURRENT_THRESHOLD_OPEN_MA = 0x0003 REG_HOLDING_END_CURRENT_THRESHOLD_CLOSE_MA = 0x0004 REG_HOLDING_DIGITAL_OUTPUTS_STATE = 0x0010 REG_HOLDING_WATCHDOG_TIMEOUT_S = 0x00F0 REG_HOLDING_DEVICE_RESET = 0x00F1 REG_HOLDING_FWU_COMMAND = 0x0100 REG_HOLDING_FWU_CHUNK_OFFSET_LOW = 0x0101 REG_HOLDING_FWU_CHUNK_OFFSET_HIGH = 0x0102 REG_HOLDING_FWU_CHUNK_SIZE = 0x0103 REG_HOLDING_FWU_DATA_BUFFER = 0x0180 # --- 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 new_data["error"] = None # Clear error on successful reconnect 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=4, 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["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 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 == "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(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()