feat: Integrate VND7050AJ driver and enhance gateway settings
This commit introduces the VND7050AJ driver as a new submodule and integrates it into the project. Key changes include: - Added as a git submodule. - Enhanced the gateway application () with LittleFS and the settings subsystem. - Implemented new shell commands (, , ) for managing custom settings. - Added functionality to compact the settings file. - Updated to include new library dependencies and log return code. - Adjusted include paths for in relevant files. Signed-off-by: Eduard Iten <eduard@iten.pro>
This commit is contained in:
216
software/tools/modbus_valve_simulator/modbus_valve_simulator.py
Normal file
216
software/tools/modbus_valve_simulator/modbus_valve_simulator.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import argparse
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import serial
|
||||
import asyncio
|
||||
from pymodbus.server import StartSerialServer
|
||||
from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
|
||||
from pymodbus.framer.rtu import FramerRTU
|
||||
|
||||
# --- Configuration ---
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.FileHandler('communication_debug.log', 'w')])
|
||||
log = logging.getLogger()
|
||||
logging.getLogger("pymodbus").setLevel(logging.DEBUG)
|
||||
|
||||
# --- Constants from Documentation ---
|
||||
# Input Registers (3xxxx)
|
||||
REG_INPUT_VALVE_STATE_MOVEMENT = 0x0000
|
||||
REG_INPUT_MOTOR_OPEN_CURRENT_MA = 0x0001
|
||||
REG_INPUT_MOTOR_CLOSE_CURRENT_MA = 0x0002
|
||||
REG_INPUT_SUPPLY_VOLTAGE_MV = 0x00F5
|
||||
|
||||
# Holding Registers (4xxxx)
|
||||
REG_HOLDING_VALVE_COMMAND = 0x0000
|
||||
|
||||
# --- Simulation Parameters ---
|
||||
SUPPLY_VOLTAGE = 12345 # mV
|
||||
MOTOR_CURRENT_IDLE = 0 # mA
|
||||
MOTOR_CURRENT_MOVING = 80 # mA
|
||||
VALVE_TRAVEL_TIME = 4.5 # seconds
|
||||
|
||||
# --- Valve Logic ---
|
||||
class ValveController:
|
||||
"""Holds the state and logic for the simulated valve based on documentation."""
|
||||
def __init__(self, node_id):
|
||||
self.node_id = node_id
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Internal State
|
||||
self.movement = 0 # 0=Idle, 1=Öffnet, 2=Schliesst
|
||||
self.state = 0 # 0=Geschlossen, 1=Geöffnet
|
||||
self.is_moving = False
|
||||
self.movement_start_time = 0
|
||||
self.target_state = 0 # 0 for close, 1 for open
|
||||
|
||||
def start_movement(self, command):
|
||||
"""Initiates a valve movement based on a holding register command."""
|
||||
with self.lock:
|
||||
if self.is_moving:
|
||||
log.warning(f"[Node {self.node_id}] Valve is already moving. Ignoring command.")
|
||||
return
|
||||
|
||||
# Command 1: Open
|
||||
if command == 1 and self.state == 0:
|
||||
log.info(f"[Node {self.node_id}] Received command to OPEN valve.")
|
||||
self.movement = 1 # Öffnet
|
||||
self.target_state = 1 # Geöffnet
|
||||
# Command 2: Close
|
||||
elif command == 2 and self.state == 1:
|
||||
log.info(f"[Node {self.node_id}] Received command to CLOSE valve.")
|
||||
self.movement = 2 # Schliesst
|
||||
self.target_state = 0 # Geschlossen
|
||||
# Command 0: Stop
|
||||
elif command == 0:
|
||||
if self.is_moving:
|
||||
log.info(f"[Node {self.node_id}] Received command to STOP valve.")
|
||||
self.is_moving = False
|
||||
self.movement = 0 # Idle
|
||||
return
|
||||
else:
|
||||
log.info(f"[Node {self.node_id}] Valve is already in the requested state or command is invalid.")
|
||||
return
|
||||
|
||||
self.is_moving = True
|
||||
self.movement_start_time = time.time()
|
||||
|
||||
def update_state(self):
|
||||
"""Updates the valve's position and state if it's moving."""
|
||||
with self.lock:
|
||||
if not self.is_moving:
|
||||
return
|
||||
|
||||
elapsed_time = time.time() - self.movement_start_time
|
||||
|
||||
if elapsed_time >= VALVE_TRAVEL_TIME:
|
||||
# Movement is complete
|
||||
self.is_moving = False
|
||||
self.state = self.target_state
|
||||
self.movement = 0 # Idle
|
||||
log.info(f"[Node {self.node_id}] Valve movement finished. State: {'Open' if self.state == 1 else 'Closed'}")
|
||||
|
||||
# --- Modbus Datastore Blocks ---
|
||||
class CustomDataBlock(ModbusSequentialDataBlock):
|
||||
def __init__(self, controller):
|
||||
self.controller = controller
|
||||
# Initialize registers to a safe default size, they will be dynamically updated.
|
||||
super().__init__(0, [0] * 256)
|
||||
|
||||
def setValues(self, address, values):
|
||||
# Handle writes to the VALVE_COMMAND register
|
||||
if address == REG_HOLDING_VALVE_COMMAND:
|
||||
if values:
|
||||
self.controller.start_movement(values[0])
|
||||
else:
|
||||
log.info(f"[Node {self.controller.node_id}] Write to unhandled holding register 0x{address:04X} with value(s): {values}")
|
||||
super().setValues(address, values)
|
||||
|
||||
def getValues(self, address, count=1):
|
||||
self.controller.update_state() # Update valve state before returning values
|
||||
log.debug(f"getValues: requested address={address}")
|
||||
|
||||
# Handle specific input registers
|
||||
if (address - 1) == REG_INPUT_VALVE_STATE_MOVEMENT:
|
||||
valve_state_movement = (self.controller.movement << 8) | self.controller.state
|
||||
return [valve_state_movement]
|
||||
elif (address - 1) == REG_INPUT_MOTOR_OPEN_CURRENT_MA:
|
||||
motor_current = MOTOR_CURRENT_MOVING if self.controller.movement == 1 else MOTOR_CURRENT_IDLE
|
||||
return [motor_current]
|
||||
elif (address - 1) == REG_INPUT_MOTOR_CLOSE_CURRENT_MA:
|
||||
motor_current = MOTOR_CURRENT_MOVING if self.controller.movement == 2 else MOTOR_CURRENT_IDLE
|
||||
return [motor_current]
|
||||
elif (address - 1) == REG_INPUT_SUPPLY_VOLTAGE_MV:
|
||||
return [SUPPLY_VOLTAGE]
|
||||
else:
|
||||
# For any other register, return 0xbeaf
|
||||
return [0xbeaf] * count
|
||||
|
||||
# --- Main Server ---
|
||||
async def run_server(port, node_id, baudrate):
|
||||
"""Sets up and runs the Modbus RTU server."""
|
||||
controller = ValveController(node_id)
|
||||
datablock = CustomDataBlock(controller)
|
||||
|
||||
store = ModbusSlaveContext(
|
||||
di=datablock, # Input Registers
|
||||
co=None, # Coils (not used)
|
||||
hr=datablock, # Holding Registers
|
||||
ir=datablock, # Re-using for simplicity, maps to the same logic
|
||||
)
|
||||
context = ModbusServerContext(slaves={node_id: store}, single=False)
|
||||
|
||||
log.info(f"Starting Modbus RTU Valve Simulator on {port}")
|
||||
log.info(f"Node ID: {node_id}, Baudrate: {baudrate}")
|
||||
log.info("--- Register Map ---")
|
||||
log.info("Input Registers (Read-Only):")
|
||||
log.info(f" 0x{REG_INPUT_VALVE_STATE_MOVEMENT:04X}: VALVE_STATE_MOVEMENT")
|
||||
log.info(f" 0x{REG_INPUT_MOTOR_OPEN_CURRENT_MA:04X}: MOTOR_OPEN_CURRENT_MA")
|
||||
log.info(f" 0x{REG_INPUT_MOTOR_CLOSE_CURRENT_MA:04X}: MOTOR_CLOSE_CURRENT_MA")
|
||||
log.info(f" 0x{REG_INPUT_SUPPLY_VOLTAGE_MV:04X}: SUPPLY_VOLTAGE_MV")
|
||||
log.info("Holding Registers (Read/Write):")
|
||||
log.info(f" 0x{REG_HOLDING_VALVE_COMMAND:04X}: VALVE_COMMAND (1=Open, 2=Close, 0=Stop)")
|
||||
|
||||
log.info("Server listening.")
|
||||
while True:
|
||||
try:
|
||||
data = ser.read(ser.in_waiting or 1) # Read available data or wait for 1 byte
|
||||
if data:
|
||||
# Process the request
|
||||
request = framer.processIncomingPacket(data)
|
||||
if request:
|
||||
log_line = "<-- Reg: "
|
||||
reg_addr_hex = f"0x{request.address:04X}" if hasattr(request, 'address') else "N/A"
|
||||
rw_indicator = ""
|
||||
reg_type_indicator = ""
|
||||
data_info = ""
|
||||
|
||||
if request.function_code in [0x01, 0x02, 0x03, 0x04]: # Read operations
|
||||
rw_indicator = "r"
|
||||
if request.function_code == 0x03: reg_type_indicator = "Hold."
|
||||
elif request.function_code == 0x04: reg_type_indicator = "Input"
|
||||
log_line += f"{reg_addr_hex}{rw_indicator}"
|
||||
elif request.function_code in [0x05, 0x06, 0x0F, 0x10]: # Write operations
|
||||
rw_indicator = "w"
|
||||
if request.function_code == 0x06 or request.function_code == 0x10: reg_type_indicator = "Hold."
|
||||
elif request.function_code == 0x05 or request.function_code == 0x0F: reg_type_indicator = "Coil"
|
||||
|
||||
if hasattr(request, 'value') and request.value is not None: # For single write (0x05, 0x06)
|
||||
data_info = f" Data: 0x{request.value:04X}"
|
||||
elif hasattr(request, 'values') and request.values is not None: # For multiple write (0x0F, 0x10)
|
||||
data_info = " Data: 0x" + "".join([f"{val:04X}" for val in request.values])
|
||||
elif hasattr(request, 'bits') and request.bits is not None: # For multiple coil write (0x0F)
|
||||
data_info = " Data: 0x" + "".join([f"{int(bit):X}" for bit in request.bits])
|
||||
|
||||
log_line += f"{reg_addr_hex}{rw_indicator} Type: {reg_type_indicator}{data_info}"
|
||||
else:
|
||||
log_line = f"<-- Func: 0x{request.function_code:02X} Raw: {data.hex()}"
|
||||
|
||||
print(log_line)
|
||||
sys.stdout.flush()
|
||||
|
||||
response = request.execute(context)
|
||||
if response:
|
||||
pdu = framer.buildPacket(response)
|
||||
ser.write(pdu)
|
||||
|
||||
response_reg_addr = f"0x{request.address:04X}" if hasattr(request, 'address') else "N/A"
|
||||
response_data_hex = ""
|
||||
response_data_dec = ""
|
||||
|
||||
if hasattr(response, 'registers') and response.registers is not None:
|
||||
response_data_hex = "".join([f"{val:04X}" for val in response.registers])
|
||||
response_data_dec = ", ".join([str(val) for val in response.registers])
|
||||
elif hasattr(response, 'bits') and response.bits is not None:
|
||||
response_data_hex = "".join([f"{int(bit):X}" for bit in response.bits])
|
||||
response_data_dec = ", ".join([str(int(bit)) for bit in response.bits])
|
||||
elif hasattr(response, 'value') and response.value is not None: # For single write response
|
||||
response_data_hex = f"{response.value:04X}"
|
||||
response_data_dec = str(response.value)
|
||||
|
||||
print(f"--> Reg: {response_reg_addr} Data: 0x{response_data_hex} (Dec: {response_data_dec})")
|
||||
sys.stdout.flush()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during serial communication: {e}")
|
||||
sys.stderr.flush()
|
||||
await asyncio.sleep(0.001) # Small delay to prevent busy-waiting
|
||||
2
software/tools/modbus_valve_simulator/requirements.txt
Normal file
2
software/tools/modbus_valve_simulator/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
pymodbus
|
||||
pyserial
|
||||
58
software/tools/modbus_valve_simulator/setup_virtual_comports.sh
Executable file
58
software/tools/modbus_valve_simulator/setup_virtual_comports.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# This script creates a pair of virtual serial ports (pseudo-terminals)
|
||||
# that are linked together, allowing two applications to communicate
|
||||
# as if they were connected by a physical null-modem cable.
|
||||
#
|
||||
# It uses `socat`, a powerful command-line utility for data transfer.
|
||||
|
||||
# --- Check for socat ---
|
||||
if ! command -v socat &> /dev/null
|
||||
then
|
||||
echo "Error: 'socat' is not installed. It is required to create virtual serial ports."
|
||||
echo "Please install it using your package manager."
|
||||
echo "For Debian/Ubuntu: sudo apt-get update && sudo apt-get install socat"
|
||||
echo "For Fedora: sudo dnf install socat"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Configuration ---
|
||||
# The script will create symlinks to the virtual ports in the current directory
|
||||
# for easy access.
|
||||
PORT1="./vcom_a"
|
||||
PORT2="./vcom_b"
|
||||
|
||||
# --- Cleanup function ---
|
||||
# This function will be called when the script exits to remove the symlinks.
|
||||
trap 'cleanup' EXIT
|
||||
|
||||
cleanup() {
|
||||
echo -e "\nCleaning up..."
|
||||
rm -f "$PORT1" "$PORT2"
|
||||
echo "Removed symlinks '$PORT1' and '$PORT2'."
|
||||
}
|
||||
|
||||
|
||||
# --- Main Execution ---
|
||||
echo "=================================================="
|
||||
echo " Virtual Serial Port Pair Setup"
|
||||
echo "=================================================="
|
||||
echo
|
||||
echo "Creating a linked pair of virtual serial ports."
|
||||
echo " - Port A will be available at: $PORT1"
|
||||
echo " - Port B will be available at: $PORT2"
|
||||
echo
|
||||
echo "You can now connect the simulator to one port and your client script to the other."
|
||||
echo "Example:"
|
||||
echo " Terminal 1: python modbus_valve_simulator.py $PORT1"
|
||||
echo " Terminal 2: python your_client_script.py $PORT2"
|
||||
echo
|
||||
echo "Press [Ctrl+C] to shut down the virtual ports and exit."
|
||||
echo "--------------------------------------------------"
|
||||
|
||||
# The core command.
|
||||
# -d -d: Increases verbosity to show data transfer.
|
||||
# pty: Creates a pseudo-terminal (virtual port).
|
||||
# raw,echo=0: Puts the terminal in raw mode, suitable for serial data.
|
||||
# link=<path>: Creates a symbolic link to the PTY device for a stable name.
|
||||
socat -d -d pty,raw,echo=0,link="$PORT1" pty,raw,echo=0,link="$PORT2"
|
||||
Reference in New Issue
Block a user