456 lines
19 KiB
Python
456 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Multi-Client Modbus Test - Tests how many concurrent Modbus clients the ESP32 can handle
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import logging
|
|
import time
|
|
import threading
|
|
import statistics
|
|
from datetime import datetime
|
|
from multiprocessing import Process, Queue, Value
|
|
from pymodbus.client import ModbusTcpClient
|
|
from pymodbus.exceptions import ConnectionException
|
|
|
|
# --- Configuration ---
|
|
MODBUS_PORT = 502
|
|
MB_BATTLE_COUNTER_REG = 20 # Counter register address
|
|
MB_BATTLE_TIMESTAMP_REG = 21 # Timestamp register address
|
|
MB_CLIENT_COUNT_REG = 22 # Current number of connected clients
|
|
MB_CLIENT_MAX_REG = 23 # Maximum number of clients seen
|
|
MB_CLIENT_TOTAL_REG = 24 # Total client connections since start
|
|
OUTPUT_DIR = "tmp"
|
|
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "multi-client-test.json")
|
|
LOG_LEVEL = logging.INFO
|
|
|
|
# --- Setup Logging ---
|
|
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
|
# --- Argument Parsing ---
|
|
parser = argparse.ArgumentParser(description='Multi-Client Modbus Test - Test concurrent client connections')
|
|
parser.add_argument('--ip-address', type=str, default='192.168.1.250',
|
|
help='IP address of the Modbus TCP server (ESP32), defaults to 192.168.1.250')
|
|
parser.add_argument('--clients', type=int, default=4,
|
|
help='Number of concurrent clients to test (default: 4)')
|
|
parser.add_argument('--operations', type=int, default=100,
|
|
help='Number of operations per client (default: 100)')
|
|
parser.add_argument('--delay', type=float, default=0.01,
|
|
help='Delay in seconds between client operations (default: 0.01)')
|
|
parser.add_argument('--sequential', action='store_true',
|
|
help='Run sequential client test')
|
|
args = parser.parse_args()
|
|
|
|
# Create output directory
|
|
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
|
|
|
# --- Client Process Function ---
|
|
def client_process(client_id, ip_address, operations, delay, results_queue, stop_flag):
|
|
"""Run a client process that connects and performs operations"""
|
|
client = None
|
|
operations_count = 0
|
|
successful_operations = 0
|
|
failed_operations = 0
|
|
connection_attempts = 0
|
|
successful_connections = 0
|
|
failed_connections = 0
|
|
write_times = []
|
|
read_times = []
|
|
round_trip_times = []
|
|
|
|
try:
|
|
logging.info(f"Client {client_id}: Starting")
|
|
|
|
# Keep trying to connect and perform operations until we reach the total
|
|
while operations_count < operations and not stop_flag.value:
|
|
# Connect if not connected
|
|
if client is None or not client.is_socket_open():
|
|
connection_attempts += 1
|
|
try:
|
|
client = ModbusTcpClient(ip_address, port=MODBUS_PORT)
|
|
connected = client.connect()
|
|
if connected:
|
|
successful_connections += 1
|
|
logging.info(f"Client {client_id}: Connected")
|
|
else:
|
|
failed_connections += 1
|
|
logging.warning(f"Client {client_id}: Connection failed")
|
|
time.sleep(1) # Wait a bit before retrying
|
|
continue
|
|
except Exception as e:
|
|
failed_connections += 1
|
|
logging.error(f"Client {client_id}: Connection error: {e}")
|
|
time.sleep(1) # Wait a bit before retrying
|
|
continue
|
|
|
|
# Perform a read-write-read operation
|
|
try:
|
|
operations_count += 1
|
|
|
|
# Read current counter value
|
|
read_start = time.time()
|
|
read_response = client.read_holding_registers(address=MB_BATTLE_COUNTER_REG, count=1)
|
|
read_end = time.time()
|
|
read_time = (read_end - read_start) * 1000 # Convert to ms
|
|
|
|
if read_response.isError():
|
|
failed_operations += 1
|
|
logging.error(f"Client {client_id}: Read error: {read_response}")
|
|
continue
|
|
|
|
current_value = read_response.registers[0]
|
|
|
|
# Write incremented value
|
|
write_start = time.time()
|
|
write_response = client.write_register(address=MB_BATTLE_COUNTER_REG, value=current_value+1)
|
|
write_end = time.time()
|
|
write_time = (write_end - write_start) * 1000 # Convert to ms
|
|
|
|
if write_response.isError():
|
|
failed_operations += 1
|
|
logging.error(f"Client {client_id}: Write error: {write_response}")
|
|
continue
|
|
|
|
# Verify the write
|
|
verify_start = time.time()
|
|
verify_response = client.read_holding_registers(address=MB_BATTLE_COUNTER_REG, count=1)
|
|
verify_end = time.time()
|
|
verify_time = (verify_end - verify_start) * 1000 # Convert to ms
|
|
|
|
round_trip_time = (verify_end - read_start) * 1000 # Full operation time in ms
|
|
|
|
if verify_response.isError():
|
|
failed_operations += 1
|
|
logging.error(f"Client {client_id}: Verification error: {verify_response}")
|
|
continue
|
|
|
|
new_value = verify_response.registers[0]
|
|
if new_value != current_value + 1:
|
|
logging.warning(f"Client {client_id}: Value mismatch: expected {current_value+1}, got {new_value}")
|
|
|
|
# Success - record times
|
|
successful_operations += 1
|
|
write_times.append(write_time)
|
|
read_times.append((read_time + verify_time) / 2) # Average of both reads
|
|
round_trip_times.append(round_trip_time)
|
|
|
|
# Progress reporting
|
|
if operations_count % 10 == 0:
|
|
logging.info(f"Client {client_id}: Completed {operations_count}/{operations} operations")
|
|
|
|
# Add delay between operations
|
|
if delay > 0:
|
|
time.sleep(delay)
|
|
|
|
except ConnectionException as ce:
|
|
failed_operations += 1
|
|
logging.error(f"Client {client_id}: Connection error during operation: {ce}")
|
|
client = None # Will try to reconnect
|
|
except Exception as e:
|
|
failed_operations += 1
|
|
logging.error(f"Client {client_id}: Operation error: {e}")
|
|
# Continue with next operation
|
|
|
|
# Clean up
|
|
if client and client.is_socket_open():
|
|
client.close()
|
|
|
|
# Report results
|
|
results = {
|
|
"client_id": client_id,
|
|
"operations": {
|
|
"total": operations_count,
|
|
"successful": successful_operations,
|
|
"failed": failed_operations
|
|
},
|
|
"connections": {
|
|
"attempts": connection_attempts,
|
|
"successful": successful_connections,
|
|
"failed": failed_connections
|
|
},
|
|
"times_ms": {
|
|
"write": {
|
|
"min": min(write_times) if write_times else None,
|
|
"max": max(write_times) if write_times else None,
|
|
"mean": statistics.mean(write_times) if write_times else None,
|
|
"median": statistics.median(write_times) if write_times else None
|
|
},
|
|
"read": {
|
|
"min": min(read_times) if read_times else None,
|
|
"max": max(read_times) if read_times else None,
|
|
"mean": statistics.mean(read_times) if read_times else None,
|
|
"median": statistics.median(read_times) if read_times else None
|
|
},
|
|
"round_trip": {
|
|
"min": min(round_trip_times) if round_trip_times else None,
|
|
"max": max(round_trip_times) if round_trip_times else None,
|
|
"mean": statistics.mean(round_trip_times) if round_trip_times else None,
|
|
"median": statistics.median(round_trip_times) if round_trip_times else None
|
|
}
|
|
},
|
|
"all_times": {
|
|
"write": write_times,
|
|
"read": read_times,
|
|
"round_trip": round_trip_times
|
|
}
|
|
}
|
|
results_queue.put(results)
|
|
logging.info(f"Client {client_id}: Finished - {successful_operations}/{operations_count} successful operations")
|
|
|
|
except Exception as e:
|
|
logging.error(f"Client {client_id}: Unexpected error: {e}")
|
|
results_queue.put({
|
|
"client_id": client_id,
|
|
"error": str(e),
|
|
"operations": {
|
|
"total": operations_count,
|
|
"successful": successful_operations,
|
|
"failed": failed_operations
|
|
}
|
|
})
|
|
finally:
|
|
if client and client.is_socket_open():
|
|
client.close()
|
|
|
|
def main():
|
|
# Reset the counter before starting the test
|
|
try:
|
|
reset_client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
|
|
if reset_client.connect():
|
|
reset_client.write_register(address=MB_BATTLE_COUNTER_REG, value=0)
|
|
reset_client.write_register(address=MB_CLIENT_COUNT_REG, value=0)
|
|
reset_client.write_register(address=MB_CLIENT_MAX_REG, value=0)
|
|
reset_client.write_register(address=MB_CLIENT_TOTAL_REG, value=0)
|
|
reset_client.close()
|
|
logging.info("Reset counter and client statistics")
|
|
else:
|
|
logging.error("Failed to connect for reset")
|
|
except Exception as e:
|
|
logging.error(f"Error during reset: {e}")
|
|
|
|
# Add a sequential client test instead of parallel processing
|
|
if args.clients > 0 and args.operations == 1:
|
|
run_sequential_test(args.ip_address, args.clients, args.delay)
|
|
return
|
|
|
|
# Create shared objects for normal parallel test
|
|
results_queue = Queue()
|
|
stop_flag = Value('i', 0)
|
|
|
|
# Start client processes
|
|
processes = []
|
|
start_time = time.time()
|
|
|
|
try:
|
|
logging.info(f"Starting {args.clients} client processes...")
|
|
for i in range(args.clients):
|
|
p = Process(target=client_process,
|
|
args=(i+1, args.ip_address, args.operations, args.delay, results_queue, stop_flag))
|
|
processes.append(p)
|
|
p.start()
|
|
# Small delay between starting clients to avoid connection race
|
|
time.sleep(0.1)
|
|
|
|
# Wait for all processes to complete
|
|
for p in processes:
|
|
p.join()
|
|
|
|
except KeyboardInterrupt:
|
|
logging.info("Test interrupted by user")
|
|
stop_flag.value = 1
|
|
for p in processes:
|
|
if p.is_alive():
|
|
p.join(timeout=1)
|
|
if p.is_alive():
|
|
p.terminate()
|
|
|
|
# Calculate total test time
|
|
total_time = time.time() - start_time
|
|
|
|
# Collect results
|
|
results = []
|
|
while not results_queue.empty():
|
|
results.append(results_queue.get())
|
|
|
|
# Get final client statistics
|
|
client_stats = {
|
|
"count": 0,
|
|
"max": 0,
|
|
"total": 0
|
|
}
|
|
|
|
try:
|
|
stats_client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
|
|
if stats_client.connect():
|
|
response = stats_client.read_holding_registers(address=MB_CLIENT_COUNT_REG, count=3)
|
|
if not response.isError():
|
|
client_stats["count"] = response.registers[0]
|
|
client_stats["max"] = response.registers[1]
|
|
client_stats["total"] = response.registers[2]
|
|
stats_client.close()
|
|
except Exception as e:
|
|
logging.error(f"Error getting client stats: {e}")
|
|
|
|
# Aggregate statistics
|
|
total_operations = sum(r["operations"]["total"] for r in results)
|
|
successful_operations = sum(r["operations"]["successful"] for r in results if "operations" in r and "successful" in r["operations"])
|
|
failed_operations = sum(r["operations"]["failed"] for r in results if "operations" in r and "failed" in r["operations"])
|
|
|
|
# Calculate operations per second
|
|
ops_per_second = successful_operations / total_time if total_time > 0 else 0
|
|
|
|
# Prepare summary
|
|
summary = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"test_parameters": {
|
|
"ip_address": args.ip_address,
|
|
"clients": args.clients,
|
|
"operations_per_client": args.operations,
|
|
"delay": args.delay
|
|
},
|
|
"results": {
|
|
"total_time_seconds": total_time,
|
|
"operations": {
|
|
"total": total_operations,
|
|
"successful": successful_operations,
|
|
"failed": failed_operations,
|
|
"success_rate": (successful_operations / total_operations * 100) if total_operations > 0 else 0
|
|
},
|
|
"performance": {
|
|
"operations_per_second": ops_per_second,
|
|
"operations_per_second_per_client": ops_per_second / args.clients if args.clients > 0 else 0
|
|
},
|
|
"client_stats": client_stats
|
|
},
|
|
"client_details": results
|
|
}
|
|
|
|
# Save results to file
|
|
with open(OUTPUT_FILE, 'w') as f:
|
|
json.dump(summary, f, indent=2)
|
|
|
|
# Print summary
|
|
print("\n--- Multi-Client Modbus Test Results ---")
|
|
print(f"Clients: {args.clients}")
|
|
print(f"Operations per client: {args.operations}")
|
|
print(f"Total test time: {total_time:.2f} seconds")
|
|
print(f"Total operations: {total_operations}")
|
|
print(f"Successful operations: {successful_operations}")
|
|
print(f"Failed operations: {failed_operations}")
|
|
print(f"Success rate: {summary['results']['operations']['success_rate']:.2f}%")
|
|
print(f"Operations per second (total): {ops_per_second:.2f}")
|
|
print(f"Operations per second (per client): {summary['results']['performance']['operations_per_second_per_client']:.2f}")
|
|
print(f"Server client stats - current: {client_stats['count']}, max: {client_stats['max']}, total: {client_stats['total']}")
|
|
print("--------------------------------------\n")
|
|
print(f"Full results saved to {OUTPUT_FILE}")
|
|
|
|
def run_sequential_test(ip_address, max_clients, delay_between_clients):
|
|
"""Test how many clients the server can handle by connecting them one by one"""
|
|
logging.info(f"Starting sequential client test with up to {max_clients} clients...")
|
|
|
|
# Array to hold all clients
|
|
clients = []
|
|
clients_connected = 0
|
|
max_connected = 0
|
|
client_data = {}
|
|
|
|
try:
|
|
# Try to connect clients one by one
|
|
for i in range(1, max_clients + 1):
|
|
logging.info(f"Connecting client {i}...")
|
|
|
|
try:
|
|
# Create and connect a new client
|
|
client = ModbusTcpClient(ip_address, port=MODBUS_PORT)
|
|
connected = client.connect()
|
|
|
|
if connected:
|
|
clients.append(client)
|
|
clients_connected += 1
|
|
max_connected = max(max_connected, clients_connected)
|
|
|
|
# Read counter to confirm connection is working
|
|
response = client.read_holding_registers(address=MB_BATTLE_COUNTER_REG, count=1)
|
|
if not response.isError():
|
|
value = response.registers[0]
|
|
logging.info(f"Client {i} connected successfully. Read value: {value}")
|
|
else:
|
|
logging.warning(f"Client {i} connected but read failed: {response}")
|
|
|
|
# Wait between connections
|
|
time.sleep(delay_between_clients)
|
|
else:
|
|
logging.error(f"Client {i} failed to connect")
|
|
break
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error connecting client {i}: {e}")
|
|
break
|
|
|
|
# Get stats
|
|
if clients_connected > 0:
|
|
# Try to read with the first client
|
|
try:
|
|
response = clients[0].read_holding_registers(address=MB_CLIENT_COUNT_REG, count=3)
|
|
if not response.isError():
|
|
client_data["count"] = response.registers[0]
|
|
client_data["max"] = response.registers[1]
|
|
client_data["total"] = response.registers[2]
|
|
except Exception as e:
|
|
logging.error(f"Error reading client stats: {e}")
|
|
|
|
# Hold connections for a moment
|
|
logging.info(f"Successfully connected {clients_connected} clients. Waiting 5 seconds...")
|
|
time.sleep(5)
|
|
|
|
except KeyboardInterrupt:
|
|
logging.info("Test interrupted by user")
|
|
finally:
|
|
# Close all clients
|
|
for i, client in enumerate(clients):
|
|
if client.is_socket_open():
|
|
try:
|
|
client.close()
|
|
logging.info(f"Disconnected client {i+1}")
|
|
except:
|
|
pass
|
|
|
|
# Report results
|
|
print("\n--- Sequential Client Test Results ---")
|
|
print(f"Maximum clients attempted: {max_clients}")
|
|
print(f"Clients successfully connected: {clients_connected}")
|
|
print(f"Success rate: {(clients_connected / max_clients * 100) if max_clients > 0 else 0:.2f}%")
|
|
|
|
if client_data:
|
|
print(f"Server reported - clients: {client_data.get('count', 'N/A')}, max: {client_data.get('max', 'N/A')}, total: {client_data.get('total', 'N/A')}")
|
|
|
|
print("--------------------------------------\n")
|
|
|
|
# Save results
|
|
results = {
|
|
"timestamp": datetime.now().isoformat(),
|
|
"test_type": "sequential",
|
|
"parameters": {
|
|
"ip_address": ip_address,
|
|
"max_clients": max_clients,
|
|
"delay": delay_between_clients
|
|
},
|
|
"results": {
|
|
"clients_connected": clients_connected,
|
|
"success_rate": (clients_connected / max_clients * 100) if max_clients > 0 else 0,
|
|
"client_stats": client_data
|
|
}
|
|
}
|
|
|
|
with open(OUTPUT_FILE, 'w') as f:
|
|
json.dump(results, f, indent=2)
|
|
|
|
logging.info(f"Results saved to {OUTPUT_FILE}")
|
|
|
|
if __name__ == "__main__":
|
|
# Override for sequential test
|
|
if args.sequential:
|
|
args.operations = 1
|
|
|
|
main() |