This commit introduces a new Modbus input register for the system's supply voltage. - The `modbus-registers.de.md` documentation is updated to include the `SUPPLY_VOLTAGE_MV` register at address `0x00F5` within the system block. - The `modbus_server.h` header defines the new register. - The `modbus_server.c` implementation provides a fixed value (12300 mV) for this register. - The `modbus_tool.py` script is updated to read and display this new supply voltage value in the UI. This lays the groundwork for integrating actual voltage measurements in the future.
335 lines
17 KiB
Python
Executable File
335 lines
17 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)
|
|
REG_INPUT_VALVE_STATE_MOVEMENT = 0x0000
|
|
REG_INPUT_MOTOR_CURRENT_MA = 0x0001
|
|
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_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=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=2, 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_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"] = f"{ir_valve.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["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.clear()
|
|
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", "Toggle Output 1", "Toggle Output 2", "Set Watchdog", "Reset Node", "Firmware Update", "Exit"]
|
|
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(menu)
|
|
elif key == curses.KEY_DOWN: current_row_idx = (current_row_idx + 1) % len(menu)
|
|
elif key == curses.KEY_ENTER or key in [10, 13]:
|
|
selected_option = menu[current_row_idx]
|
|
message_time = time.time()
|
|
if selected_option == "Exit": stop_event.set(); 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 "Toggle Output" in selected_option:
|
|
bit = 0 if "1" in selected_option else 1
|
|
try:
|
|
current_val = client.read_holding_registers(REG_HOLDING_DIGITAL_OUTPUTS_STATE, count=1, slave=slave_id).registers[0]
|
|
client.write_register(REG_HOLDING_DIGITAL_OUTPUTS_STATE, current_val ^ (1 << bit), slave=slave_id)
|
|
message = f"-> Toggled Output {bit+1}"
|
|
except Exception as e: message = f"-> Error: {e}"
|
|
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.clear()
|
|
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, "Motor Current:", bold); stdscr.addstr(3, col1 + 18, str(current_data.get('motor_current', '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(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(5, 0, "─" * (w - 1), normal)
|
|
for idx, row in enumerate(menu):
|
|
draw_button(stdscr, h // 2 - len(menu) + (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)
|
|
stdscr.refresh()
|
|
|
|
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()
|