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:
2025-07-17 15:18:22 +02:00
parent 0713f8255e
commit d76b897eb2
32 changed files with 1048 additions and 23 deletions

View 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

View File

@@ -0,0 +1,2 @@
pymodbus
pyserial

View 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"