polymech - fw latest | web ui

This commit is contained in:
2026-04-18 10:31:24 +02:00
parent a105c5ee85
commit ab2ff368a6
2972 changed files with 441416 additions and 372 deletions
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Simple script to check a specific REST API endpoint
"""
import requests
import json
import sys
def main():
if len(sys.argv) < 3:
print("Usage: python check_endpoint.py <url> <method>")
print("Example: python check_endpoint.py http://192.168.1.250/api/v1/coils/30 GET")
sys.exit(1)
url = sys.argv[1]
method = sys.argv[2].upper()
print(f"Checking {method} {url}")
try:
if method == "GET":
response = requests.get(url, timeout=5)
elif method == "POST":
data = {'value': True}
response = requests.post(url, json=data, timeout=5)
else:
print(f"Unsupported method: {method}")
sys.exit(1)
print(f"Status code: {response.status_code}")
if response.status_code != 404:
try:
print(f"Response: {json.dumps(response.json(), indent=2)}")
except:
print(f"Response: {response.text}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
import requests
import argparse
import json
import time
def fetch_logs(host):
"""Fetches logs from the device's REST API."""
url = f"http://{host}/api/v1/system/logs"
try:
response = requests.get(url, timeout=10) # Add a timeout
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
return response.json()
except requests.exceptions.ConnectionError:
print(f"Error: Could not connect to {url}. Is the device running and on the network?")
except requests.exceptions.Timeout:
print(f"Error: Request timed out connecting to {url}.")
except requests.exceptions.HTTPError as http_err:
print(f"HTTP error occurred: {http_err} - {response.status_code} {response.reason}")
try:
# Try to print the error message from the API if available
error_details = response.json()
print(f"API Error Details: {error_details}")
except json.JSONDecodeError:
print(f"Could not parse error response: {response.text}")
except requests.exceptions.RequestException as err:
print(f"An unexpected error occurred: {err}")
except json.JSONDecodeError:
print(f"Error: Could not decode JSON response from {url}.")
print(f"Raw response: {response.text[:200]}...") # Print beginning of raw response
return None
def main():
parser = argparse.ArgumentParser(description='Fetch and print logs from the device REST API.')
parser.add_argument('--host', default='modbus-esp32.local', help='Hostname or IP address of the device (default: modbus-esp32.local)')
args = parser.parse_args()
print(f"Attempting to fetch logs from {args.host}...")
logs = fetch_logs(args.host)
if logs is not None:
if isinstance(logs, list):
if not logs:
print("No logs received from the device.")
else:
print("--- Received Logs ---")
for line in logs:
print(line)
print("---------------------")
else:
print("Error: Received unexpected data format (expected a JSON list).")
print(f"Received: {logs}")
if __name__ == "__main__":
main()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""
Script to convert a single file to a C++ header file
"""
import os
import sys
def escape_char(char):
"""Escape special characters for C++ strings"""
if char == '\n':
return '\\n'
elif char == '\r':
return '\\r'
elif char == '"':
return '\\"'
elif char == '\\':
return '\\\\'
return char
def file_to_progmem_string(file_path, var_name):
"""Convert a file to a C++ PROGMEM string declaration"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Escape special characters
escaped_content = ''.join(escape_char(c) for c in content)
# Create the C++ code
progmem_string = f'const char {var_name}[] PROGMEM = R"rawliteral({content})rawliteral";'
return progmem_string
def create_header_file(input_file, output_file, var_name):
"""Create a C++ header file with a PROGMEM string variable"""
progmem_string = file_to_progmem_string(input_file, var_name)
file_name = os.path.basename(output_file).upper().replace('.', '_')
guard_name = f"{file_name}_H"
header_content = f"""#ifndef {guard_name}
#define {guard_name}
#include <pgmspace.h>
{progmem_string}
#endif // {guard_name}
"""
output_dir = os.path.dirname(output_file)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(header_content)
print(f"Created header file: {output_file}")
def main():
if len(sys.argv) < 3:
print("Usage: python file_to_header.py <input_file> <output_file> [var_name]")
print("Example: python file_to_header.py swagger.yaml src/web/swagger_content.h SWAGGER_CONTENT")
sys.exit(1)
input_file = sys.argv[1]
output_file = sys.argv[2]
# Generate variable name from filename if not provided
if len(sys.argv) > 3:
var_name = sys.argv[3]
else:
var_name = os.path.splitext(os.path.basename(input_file))[0].upper() + "_CONTENT"
create_header_file(input_file, output_file, var_name)
if __name__ == "__main__":
main()
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
import yaml
import os
import re
import sys
from jinja2 import Template
# Templates for code generation
REST_SERVER_H_TEMPLATE = """
#ifndef REST_SERVER_H
#define REST_SERVER_H
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
#include <ArduinoJson.h>
#include <ArduinoLog.h>
#include <ModbusIP_ESP8266.h>
#include "enums.h"
// Forward declarations
class ModbusIP;
/**
* @brief RESTful API server generated from Swagger spec.
* This class implements a RESTful API server that interfaces with the Modbus system.
*/
class RESTServer {
private:
AsyncWebServer *server;
ModbusIP *modbus;
// Handler methods
{% for handler in handlers %}
void {{ handler.name }}Handler(AsyncWebServerRequest *request);
{% endfor %}
public:
/**
* @brief Construct a new RESTServer object
*
* @param port The port to run the server on
* @param _modbus Pointer to the ModbusIP instance
*/
RESTServer(IPAddress ip, int port, ModbusIP *_modbus);
/**
* @brief Destroy the RESTServer object
*/
~RESTServer();
/**
* @brief Run periodically to handle server tasks
*/
void loop();
};
#endif // REST_SERVER_H
"""
REST_SERVER_CPP_TEMPLATE = """
#include "RestServer.h"
#include <Arduino.h>
#include <ArduinoJson.h>
#include <WiFi.h>
#include <ESPmDNS.h>
RESTServer::RESTServer(IPAddress ip, int port, ModbusIP *_modbus) {
server = new AsyncWebServer(port);
modbus = _modbus;
// Set up CORS
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Content-Type");
// Register routes
{% for route in routes %}
server->on("{{ route.path }}", {{ route.method }}, [this](AsyncWebServerRequest *request) {
this->{{ route.handler }}Handler(request);
});
{% endfor %}
// Handle OPTIONS requests for CORS
server->onNotFound([](AsyncWebServerRequest *request) {
if (request->method() == HTTP_OPTIONS) {
request->send(200);
} else {
request->send(404, "text/plain", "Not found");
}
});
// Start mDNS responder
if (MDNS.begin("modbus-esp32")) {
Log.verboseln("MDNS responder started. Device can be reached at http://modbus-esp32.local");
}
// Start server
server->begin();
Log.verboseln("HTTP server started on port %d", port);
}
RESTServer::~RESTServer() {
if (server) {
server->end();
delete server;
server = nullptr;
}
}
void RESTServer::loop() {
// Any periodic handling needed
}
{% for handler in handlers %}
void RESTServer::{{ handler.name }}Handler(AsyncWebServerRequest *request) {
{{ handler.code }}
}
{% endfor %}
"""
# Handler template for system info
SYSTEM_INFO_HANDLER = """
// Create JSON response with system info
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["version"] = "1.0.0";
doc["board"] = BOARD_NAME;
doc["uptime"] = millis() / 1000;
doc["timestamp"] = millis();
serializeJson(doc, *response);
request->send(response);
"""
# Handler template for getting coils
GET_COILS_HANDLER = """
int start = 0;
int count = 50;
// Get query parameters
if (request->hasParam("start")) {
start = request->getParam("start")->value().toInt();
}
if (request->hasParam("count")) {
count = request->getParam("count")->value().toInt();
if (count > 100) count = 100; // Limit to prevent large responses
}
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
JsonArray coilsArray = doc.createNestedArray("coils");
for (int i = 0; i < count; i++) {
coilsArray.add(modbus->Coil(start + i));
}
serializeJson(doc, *response);
request->send(response);
"""
# Handler template for getting a specific coil
GET_COIL_HANDLER = """
// Get path parameter
String addressStr = request->pathArg(0);
int address = addressStr.toInt();
bool value = modbus->Coil(address);
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["address"] = address;
doc["value"] = value;
serializeJson(doc, *response);
request->send(response);
"""
# Handler template for setting a coil
SET_COIL_HANDLER = """
// Get path parameter
String addressStr = request->pathArg(0);
int address = addressStr.toInt();
// Check if we have a valid body
if (request->hasParam("body", true)) {
String body = request->getParam("body", true)->value();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, body);
if (!error && doc.containsKey("value")) {
bool value = doc["value"];
modbus->Coil(address, value);
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument responseDoc;
responseDoc["success"] = true;
responseDoc["address"] = address;
responseDoc["value"] = value;
serializeJson(responseDoc, *response);
request->send(response);
} else {
request->send(400, "application/json", "{\"success\":false,\"error\":\"Invalid JSON or missing value\"}");
}
} else {
request->send(400, "application/json", "{\"success\":false,\"error\":\"Missing body\"}");
}
"""
# Handler template for getting registers
GET_REGISTERS_HANDLER = """
int start = 0;
int count = 10;
// Get query parameters
if (request->hasParam("start")) {
start = request->getParam("start")->value().toInt();
}
if (request->hasParam("count")) {
count = request->getParam("count")->value().toInt();
if (count > 50) count = 50; // Limit to prevent large responses
}
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
JsonArray registersArray = doc.createNestedArray("registers");
for (int i = 0; i < count; i++) {
registersArray.add(modbus->Hreg(start + i));
}
serializeJson(doc, *response);
request->send(response);
"""
# Handler template for getting a specific register
GET_REGISTER_HANDLER = """
// Get path parameter
String addressStr = request->pathArg(0);
int address = addressStr.toInt();
int value = modbus->Hreg(address);
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["address"] = address;
doc["value"] = value;
serializeJson(doc, *response);
request->send(response);
"""
# Handler template for setting a register
SET_REGISTER_HANDLER = """
// Get path parameter
String addressStr = request->pathArg(0);
int address = addressStr.toInt();
// Check if we have a valid body
if (request->hasParam("body", true)) {
String body = request->getParam("body", true)->value();
JsonDocument doc;
DeserializationError error = deserializeJson(doc, body);
if (!error && doc.containsKey("value")) {
int value = doc["value"];
modbus->Hreg(address, value);
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument responseDoc;
responseDoc["success"] = true;
responseDoc["address"] = address;
responseDoc["value"] = value;
serializeJson(responseDoc, *response);
request->send(response);
} else {
request->send(400, "application/json", "{\"success\":false,\"error\":\"Invalid JSON or missing value\"}");
}
} else {
request->send(400, "application/json", "{\"success\":false,\"error\":\"Missing body\"}");
}
"""
# Handler template for test relays
TEST_RELAYS_HANDLER = """
// Create JSON response
AsyncResponseStream *response = request->beginResponseStream("application/json");
JsonDocument doc;
doc["success"] = true;
doc["message"] = "Relay test initiated";
// Call the test relays method on PHApp via a callback or direct call
// This would typically be done via a static callback or global instance
// For this example, we'll assume the test is successful without actual implementation
serializeJson(doc, *response);
request->send(response);
"""
def generate_handlers_from_swagger(swagger_file):
with open(swagger_file, 'r') as f:
swagger = yaml.safe_load(f)
handlers = []
routes = []
for path, methods in swagger['paths'].items():
for method, operation in methods.items():
handler_name = operation['operationId']
# Determine the handler code based on the operation ID
handler_code = ""
if handler_name == "getSystemInfo":
handler_code = SYSTEM_INFO_HANDLER
elif handler_name == "getCoils":
handler_code = GET_COILS_HANDLER
elif handler_name == "getCoil":
handler_code = GET_COIL_HANDLER
elif handler_name == "setCoil":
handler_code = SET_COIL_HANDLER
elif handler_name == "getRegisters":
handler_code = GET_REGISTERS_HANDLER
elif handler_name == "getRegister":
handler_code = GET_REGISTER_HANDLER
elif handler_name == "setRegister":
handler_code = SET_REGISTER_HANDLER
elif handler_name == "testRelays":
handler_code = TEST_RELAYS_HANDLER
# Add the handler if we have code for it
if handler_code:
handlers.append({
'name': handler_name,
'code': handler_code
})
# Determine the HTTP method
http_method = "HTTP_GET"
if method == "post":
http_method = "HTTP_POST"
# Format the path for ESP-IDF
esp_path = path.replace("{", "").replace("}", "")
# Add the route
routes.append({
'path': "/api" + esp_path,
'method': http_method,
'handler': handler_name
})
return handlers, routes
def generate_rest_server_files(swagger_file, output_dir):
handlers, routes = generate_handlers_from_swagger(swagger_file)
# Ensure output directory exists
os.makedirs(output_dir, exist_ok=True)
# Generate header file
header_template = Template(REST_SERVER_H_TEMPLATE)
header_content = header_template.render(handlers=handlers)
with open(os.path.join(output_dir, "RestServer.h"), 'w') as f:
f.write(header_content)
# Generate cpp file
cpp_template = Template(REST_SERVER_CPP_TEMPLATE)
cpp_content = cpp_template.render(routes=routes, handlers=handlers)
with open(os.path.join(output_dir, "RestServer.cpp"), 'w') as f:
f.write(cpp_content)
print(f"Generated RESTful server files in {output_dir}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python generate_rest_server.py <swagger_file> [output_dir]")
sys.exit(1)
swagger_file = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "src"
generate_rest_server_files(swagger_file, output_dir)
+77
View File
@@ -0,0 +1,77 @@
import requests
import json
import os
import sys
import argparse
# Default device identifier (mDNS or IP)
DEFAULT_DEVICE_ID = "modbus-esp32.local"
# API endpoint
API_ENDPOINT = "/api/v1/system/boot-metrics"
# Output directory
OUTPUT_DIR = "./tmp"
OUTPUT_FILE = "boot_metrics.json"
def get_device_url(device_id):
"""Constructs the full URL for the API endpoint."""
if not device_id.startswith(("http://", "https://")):
device_id = f"http://{device_id}"
return f"{device_id}{API_ENDPOINT}"
def fetch_and_save_metrics(device_url):
"""Fetches boot and current metrics from the device and saves them to a JSON file."""
try:
print(f"Attempting to fetch metrics from: {device_url}")
response = requests.get(device_url, timeout=10) # 10 second timeout
response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
metrics_data = response.json()
print("Successfully fetched metrics:")
# Print initial and current metrics
print(json.dumps(metrics_data, indent=2))
# Ensure the output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
output_path = os.path.join(OUTPUT_DIR, OUTPUT_FILE)
# Save the data to a JSON file
with open(output_path, 'w') as f:
json.dump(metrics_data, f, indent=4)
print(f"Metrics saved to: {output_path}")
except requests.exceptions.ConnectionError as e:
print(f"Error: Could not connect to the device at {device_url}.", file=sys.stderr)
print(f"Details: {e}", file=sys.stderr)
sys.exit(1)
except requests.exceptions.Timeout:
print(f"Error: Request timed out while connecting to {device_url}.", file=sys.stderr)
sys.exit(1)
except requests.exceptions.RequestException as e:
print(f"Error: An error occurred during the request to {device_url}.", file=sys.stderr)
print(f"Details: {e}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError:
print(f"Error: Failed to decode JSON response from {device_url}.", file=sys.stderr)
print(f"Received content: {response.text}", file=sys.stderr)
sys.exit(1)
except OSError as e:
print(f"Error: Could not create directory or write file.", file=sys.stderr)
print(f"Details: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Fetch boot and current metrics from ESP32 device via REST API.")
parser.add_argument(
"--ip",
type=str,
default=DEFAULT_DEVICE_ID,
help=f"IP address or mDNS name of the device (default: {DEFAULT_DEVICE_ID})"
)
args = parser.parse_args()
url = get_device_url(args.ip)
fetch_and_save_metrics(url)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
import os
import sys
import re
def escape_char(char):
"""Escape special characters for C++ strings"""
if char == '\n':
return '\\n'
elif char == '\r':
return '\\r'
elif char == '"':
return '\\"'
elif char == '\\':
return '\\\\'
return char
def file_to_progmem_string(file_path, var_name):
"""Convert a file to a C++ PROGMEM string declaration"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Escape special characters
escaped_content = ''.join(escape_char(c) for c in content)
# Create the C++ code
progmem_string = f'const char {var_name}[] PROGMEM = R"rawliteral({escaped_content})rawliteral";'
return progmem_string
def create_header_file(input_file, output_file, var_name):
"""Create a C++ header file with a PROGMEM string variable"""
progmem_string = file_to_progmem_string(input_file, var_name)
file_name = os.path.basename(output_file).upper().replace('.', '_')
guard_name = f"{file_name}_H"
header_content = f"""#ifndef {guard_name}
#define {guard_name}
#include <pgmspace.h>
{progmem_string}
#endif // {guard_name}
"""
with open(output_file, 'w', encoding='utf-8') as f:
f.write(header_content)
print(f"Created header file: {output_file}")
def process_file(input_file, output_file, var_name):
"""Process a single file and create a header file"""
if not os.path.exists(os.path.dirname(output_file)):
os.makedirs(os.path.dirname(output_file))
create_header_file(input_file, output_file, var_name)
def process_directory(input_dir, output_dir):
"""Process all files in a directory and create header files"""
if not os.path.exists(output_dir):
os.makedirs(output_dir)
for filename in os.listdir(input_dir):
input_file = os.path.join(input_dir, filename)
if os.path.isfile(input_file):
# Generate variable name from filename
var_name = re.sub(r'[^a-zA-Z0-9_]', '_', os.path.splitext(filename)[0]).upper()
var_name = f"{var_name}_CONTENT"
# Generate output filename
output_filename = f"{os.path.splitext(filename)[0]}_content.h"
output_file = os.path.join(output_dir, output_filename)
create_header_file(input_file, output_file, var_name)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python html_to_header.py <input_path> <output_path> [var_name]")
sys.exit(1)
input_path = sys.argv[1]
output_path = sys.argv[2]
var_name = sys.argv[3] if len(sys.argv) > 3 else None
if os.path.isfile(input_path):
if var_name is None:
# Generate variable name from filename
var_name = re.sub(r'[^a-zA-Z0-9_]', '_', os.path.splitext(os.path.basename(input_path))[0]).upper()
var_name = f"{var_name}_CONTENT"
process_file(input_path, output_path, var_name)
else:
process_directory(input_path, output_path)
+367
View File
@@ -0,0 +1,367 @@
#!/usr/bin/env python3
"""
Modbus Battle Test - Tests how quickly Modbus registers can be updated
"""
import argparse
import json
import os
import logging
import time
import statistics
from datetime import datetime
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
MB_BATTLE_COUNTER_REG = 20 # Counter register address
MB_BATTLE_TIMESTAMP_REG = 21 # Timestamp register address
OUTPUT_DIR = "tmp"
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "modbus_battle.json")
LOG_LEVEL = logging.INFO
RETRY_COUNT = 3 # Number of retries for connection errors
RETRY_DELAY = 0.1 # Delay between retries in seconds
# --- Modbus Exception Code Mapping ---
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
7: "Negative Acknowledge",
8: "Memory Parity Error",
10: "Gateway Path Unavailable",
11: "Gateway Target Device Failed to Respond",
}
# --- Setup Logging ---
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Modbus Battle Test - Test how quickly Modbus registers can be updated')
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('--count', type=int, default=1000,
help='Number of update iterations to perform')
parser.add_argument('--delay', type=float, default=0.0,
help='Delay in seconds between updates (0 for max speed)')
parser.add_argument('--values', type=str, default='increment',
choices=['increment', 'random', 'alternating'],
help='Values to write: increment (1,2,3...), random, or alternating (0,1,0,1...)')
parser.add_argument('--retries', type=int, default=RETRY_COUNT,
help=f'Number of retries for connection errors (default: {RETRY_COUNT})')
parser.add_argument('--retry-delay', type=float, default=RETRY_DELAY,
help=f'Delay between retries in seconds (default: {RETRY_DELAY})')
parser.add_argument('--log-level', type=str, default='INFO',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
help='Set logging level (default: INFO)')
args = parser.parse_args()
# Set log level from command line
if args.log_level:
numeric_level = getattr(logging, args.log_level.upper(), None)
if isinstance(numeric_level, int):
logging.getLogger().setLevel(numeric_level)
# Create output directory
try:
os.makedirs(OUTPUT_DIR, exist_ok=True)
logging.info(f"Ensured output directory exists: {OUTPUT_DIR}")
except OSError as e:
logging.error(f"Failed to create output directory {OUTPUT_DIR}: {e}")
exit(1)
def get_next_value(current_value, mode, iteration):
"""Returns the next value to write based on the specified mode"""
if mode == 'increment':
return current_value + 1
elif mode == 'random':
import random
return random.randint(1, 65535)
elif mode == 'alternating':
return (iteration % 2) # Returns 0 or 1
return current_value + 1 # Default to increment
def write_with_retry(client, address, value, retries=RETRY_COUNT, retry_delay=RETRY_DELAY):
"""Write a register value with retry logic for connection errors"""
connection_reset = False
for attempt in range(retries + 1):
try:
# If we had a connection reset on previous attempt, reconnect first
if connection_reset and not client.is_socket_open():
logging.debug(f"Reconnecting after connection reset (attempt {attempt+1})")
client.connect()
# Give the server time to register the new connection
time.sleep(retry_delay * 2)
response = client.write_register(address, value)
return response, None
except ConnectionError as e:
# Connection reset errors are common when other clients disconnect
connection_reset = True
if "reset by peer" in str(e).lower() or "connection reset" in str(e).lower():
logging.debug(f"Connection reset detected, likely another client disconnected")
if attempt < retries:
# Use exponential backoff for connection issues
wait_time = retry_delay * (attempt + 1)
logging.debug(f"Retry {attempt+1}/{retries} after write error: {e} (waiting {wait_time:.2f}s)")
time.sleep(wait_time)
else:
return None, e
except Exception as e:
if attempt < retries:
# Use exponential backoff for connection issues
wait_time = retry_delay * (attempt + 1)
logging.debug(f"Retry {attempt+1}/{retries} after write error: {e} (waiting {wait_time:.2f}s)")
time.sleep(wait_time)
else:
return None, e
return None, Exception("Maximum retries exceeded")
def read_with_retry(client, address, count, retries=RETRY_COUNT, retry_delay=RETRY_DELAY):
"""Read register(s) with retry logic for connection errors"""
connection_reset = False
for attempt in range(retries + 1):
try:
# If we had a connection reset on previous attempt, reconnect first
if connection_reset and not client.is_socket_open():
logging.debug(f"Reconnecting after connection reset (attempt {attempt+1})")
client.connect()
# Give the server time to register the new connection
time.sleep(retry_delay * 2)
response = client.read_holding_registers(address=address, count=count)
return response, None
except ConnectionError as e:
# Connection reset errors are common when other clients disconnect
connection_reset = True
if "reset by peer" in str(e).lower() or "connection reset" in str(e).lower():
logging.debug(f"Connection reset detected, likely another client disconnected")
if attempt < retries:
# Use exponential backoff for connection issues
wait_time = retry_delay * (attempt + 1)
logging.debug(f"Retry {attempt+1}/{retries} after read error: {e} (waiting {wait_time:.2f}s)")
time.sleep(wait_time)
else:
return None, e
except Exception as e:
if attempt < retries:
# Use exponential backoff for connection issues
wait_time = retry_delay * (attempt + 1)
logging.debug(f"Retry {attempt+1}/{retries} after read error: {e} (waiting {wait_time:.2f}s)")
time.sleep(wait_time)
else:
return None, e
return None, Exception("Maximum retries exceeded")
def run_battle_test(client, count, delay, value_mode):
"""Performs the Modbus battle test with timing metrics"""
write_times = []
verify_times = []
round_trip_times = []
success_count = 0
current_value = 0
retry_count = args.retries
retry_delay = args.retry_delay
reconnect_count = 0
# First reset the counter to 0
try:
client.write_register(MB_BATTLE_COUNTER_REG, 0)
time.sleep(0.1) # Small delay to ensure reset takes effect
except Exception as e:
logging.error(f"Failed to reset counter: {e}")
return None
logging.info(f"Starting Modbus battle test with {count} iterations, {delay}s delay, and {value_mode} values")
logging.info(f"Using {retry_count} retries with {retry_delay}s delay between retries")
test_start_time = time.time()
for i in range(count):
next_value = get_next_value(current_value, value_mode, i)
iteration_start = time.time()
# Check connection state
if not client.is_socket_open():
logging.warning(f"Connection lost at iteration {i}, attempting to reconnect...")
try:
client.connect()
reconnect_count += 1
logging.info(f"Successfully reconnected (reconnect #{reconnect_count})")
time.sleep(retry_delay * 2) # Give server time to setup connection
except Exception as ce:
logging.error(f"Failed to reconnect: {ce}")
# Write the value and measure time
write_start = time.time()
write_success = False
response, write_error = write_with_retry(client, MB_BATTLE_COUNTER_REG, next_value,
retry_count, retry_delay)
if write_error:
logging.error(f"Exception during write at iteration {i}: {write_error}")
elif response and not response.isError():
write_success = True
current_value = next_value
else:
error_msg = MODBUS_EXCEPTIONS.get(response.exception_code, "Unknown") if response else "No response"
logging.error(f"Write error at iteration {i}: {error_msg}")
write_end = time.time()
write_time = (write_end - write_start) * 1000 # Convert to ms
write_times.append(write_time)
# Verify the value was written correctly
verify_start = time.time()
verify_success = False
if write_success:
read_response, read_error = read_with_retry(client, MB_BATTLE_COUNTER_REG, 1,
retry_count, retry_delay)
if read_error:
logging.error(f"Exception during verification at iteration {i}: {read_error}")
elif read_response and not read_response.isError() and len(read_response.registers) > 0:
read_value = read_response.registers[0]
if read_value == next_value:
verify_success = True
else:
logging.warning(f"Verification mismatch at iteration {i}: Expected {next_value}, got {read_value}")
else:
error_msg = MODBUS_EXCEPTIONS.get(read_response.exception_code, "Unknown") if read_response else "No response"
logging.error(f"Read error at iteration {i}: {error_msg}")
verify_end = time.time()
verify_time = (verify_end - verify_start) * 1000 # Convert to ms
verify_times.append(verify_time)
# Calculate round-trip time
round_trip_time = (verify_end - write_start) * 1000 # Convert to ms
round_trip_times.append(round_trip_time)
# Count successful operations
if write_success and verify_success:
success_count += 1
# Show progress
if i % 10 == 0 or i == count - 1:
elapsed = time.time() - test_start_time
percent_complete = (i+1) / count * 100
estimated_total = elapsed / (i+1) * count if i > 0 else 0
remaining = estimated_total - elapsed if estimated_total > 0 else 0
logging.info(f"Progress: {i+1}/{count} iterations ({percent_complete:.1f}%), " +
f"{success_count} successful, " +
f"ETA: {remaining:.1f}s remaining")
# Apply delay if specified
if delay > 0:
time.sleep(delay)
# Calculate statistics
success_rate = (success_count / count) * 100 if count > 0 else 0
test_elapsed_time = time.time() - test_start_time
results = {
"timestamp": datetime.now().isoformat(),
"parameters": {
"ip_address": args.ip_address,
"count": count,
"delay": delay,
"value_mode": value_mode,
"retries": retry_count,
"retry_delay": retry_delay
},
"results": {
"success_count": success_count,
"success_rate": success_rate,
"reconnect_count": reconnect_count,
"total_elapsed_seconds": test_elapsed_time,
"write_times_ms": {
"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,
"percentile_95": statistics.quantiles(write_times, n=20)[18] if len(write_times) >= 20 else None
},
"verify_times_ms": {
"min": min(verify_times) if verify_times else None,
"max": max(verify_times) if verify_times else None,
"mean": statistics.mean(verify_times) if verify_times else None,
"median": statistics.median(verify_times) if verify_times else None,
"percentile_95": statistics.quantiles(verify_times, n=20)[18] if len(verify_times) >= 20 else None
},
"round_trip_times_ms": {
"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,
"percentile_95": statistics.quantiles(round_trip_times, n=20)[18] if len(round_trip_times) >= 20 else None
},
"max_update_rate_per_second": 1000 / statistics.mean(round_trip_times) if round_trip_times else None
},
"all_data": {
"write_times": write_times,
"verify_times": verify_times,
"round_trip_times": round_trip_times
}
}
return results
# --- Main Script ---
def main():
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
connection_success = False
results = None
try:
logging.info(f"Connecting to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful")
results = run_battle_test(client, args.count, args.delay, args.values)
if results:
# Print summary to console
print("\n--- Modbus Battle Test Results ---")
print(f"Iterations: {args.count}")
print(f"Success rate: {results['results']['success_rate']:.2f}%")
print(f"Reconnections: {results['results']['reconnect_count']}")
print(f"Total time: {results['results']['total_elapsed_seconds']:.2f} seconds")
print(f"Write time (avg): {results['results']['write_times_ms']['mean']:.2f} ms")
print(f"Verify time (avg): {results['results']['verify_times_ms']['mean']:.2f} ms")
print(f"Round-trip time (avg): {results['results']['round_trip_times_ms']['mean']:.2f} ms")
print(f"Maximum update rate: {results['results']['max_update_rate_per_second']:.2f} updates/second")
# Print additional connection stats
if results['results']['reconnect_count'] > 0:
print("\nConnection Information:")
print(f" Reconnection events: {results['results']['reconnect_count']}")
print(f" Average time between reconnects: {results['results']['total_elapsed_seconds']/results['results']['reconnect_count']:.2f}s")
print("--------------------------------\n")
# Save results to file
with open(OUTPUT_FILE, 'w') as f:
json.dump(results, f, indent=2)
logging.info(f"Results saved to {OUTPUT_FILE}")
else:
logging.error(f"Failed to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}")
except Exception as e:
logging.error(f"An error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed")
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
import argparse
import json
import os
import logging
import time
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
OUTPUT_DIR = "tmp"
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "mbc-test.json")
LOG_LEVEL = logging.INFO
# --- Modbus Exception Code Mapping ---
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
7: "Negative Acknowledge",
8: "Memory Parity Error",
10: "Gateway Path Unavailable",
11: "Gateway Target Device Failed to Respond",
}
# --- Setup Logging ---
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Connect to Modbus TCP server and read coils.')
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('--address', type=int, required=True, \
help='Starting coil address to read.')
parser.add_argument('--count', type=int, default=1, \
help='Number of coils to read (default: 1).')
args = parser.parse_args()
# Use arguments for address and count
COIL_ADDRESS = args.address
COIL_COUNT = args.count
# --- Main Script ---
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
connection_success = False
results = None
read_success = False
# Create output directory
try:
os.makedirs(OUTPUT_DIR, exist_ok=True)
logging.info(f"Ensured output directory exists: {OUTPUT_DIR}")
except OSError as e:
logging.error(f"Failed to create output directory {OUTPUT_DIR}: {e}")
exit(1)
try:
logging.info(f"Attempting to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful.")
logging.info(f"Reading {COIL_COUNT} coils starting from address {COIL_ADDRESS}...")
try:
# Read Coils (Function Code 01)
response = client.read_coils(address=COIL_ADDRESS, count=COIL_COUNT)
if not response.isError():
# Access the list of boolean values
coil_values = response.bits[:COIL_COUNT]
logging.info(f"Successfully read {len(coil_values)} coil values.")
results = coil_values
read_success = True
# Print the results to the console
print("\n--- Read Coil Values ---")
for i, value in enumerate(coil_values):
print(f"Coil {COIL_ADDRESS + i}: {value}")
print("------------------------\n")
else:
# Handle Modbus logical errors
error_code = getattr(response, 'exception_code', None)
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error reading coils: Code {error_code} - {error_message}. Response: {response}")
except ConnectionException as ce:
# Handle connection errors during read
logging.error(f"Connection error during read: {ce}")
except Exception as e:
# Handle other unexpected errors during read attempt
logging.error(f"An unexpected error occurred during read: {e}")
else:
logging.error(f"Failed to connect to the Modbus TCP server at {args.ip_address}:{MODBUS_PORT}.")
except Exception as e:
# Catch errors during initial connection or loop setup
logging.error(f"An unexpected error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed.")
# Write results to JSON file only if read was successful
if read_success and results is not None:
try:
with open(OUTPUT_FILE, 'w') as f:
json.dump(results, f, indent=4)
logging.info(f"Coil results successfully written to {OUTPUT_FILE}")
except IOError as e:
logging.error(f"Failed to write results to {OUTPUT_FILE}: {e}")
else:
if connection_success and not read_success:
logging.warning("Read operation failed, JSON file not written.")
elif not connection_success:
logging.warning("Connection failed, JSON file not written.")
# Exit with error code if connection or read failed
if not connection_success or not read_success:
exit(1) # Indicate failure
+135
View File
@@ -0,0 +1,135 @@
import argparse
import json
import os
import logging
import time
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
# REGISTER_ADDRESS = 0 # Removed default, now comes from args
# REGISTER_COUNT = 10 # Removed default, now comes from args
OUTPUT_DIR = "tmp"
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "mbr-test.json") # Different output file
LOG_LEVEL = logging.INFO
# --- Modbus Exception Code Mapping ---
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
7: "Negative Acknowledge",
8: "Memory Parity Error",
10: "Gateway Path Unavailable",
11: "Gateway Target Device Failed to Respond",
}
# --- Setup Logging ---
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Connect to Modbus TCP server and read holding registers.')
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('--address', type=int, required=True, \
help='Starting holding register address to read.')
parser.add_argument('--count', type=int, default=1, \
help='Number of holding registers to read (default: 1).')
args = parser.parse_args()
# Use arguments for address and count
REGISTER_ADDRESS = args.address
REGISTER_COUNT = args.count
# --- Main Script ---
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
connection_success = False
results = None
read_success = False
# Create output directory
try:
os.makedirs(OUTPUT_DIR, exist_ok=True)
logging.info(f"Ensured output directory exists: {OUTPUT_DIR}")
except OSError as e:
logging.error(f"Failed to create output directory {OUTPUT_DIR}: {e}")
exit(1)
try:
logging.info(f"Attempting to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful.")
logging.info(f"Reading {REGISTER_COUNT} holding registers starting from address {REGISTER_ADDRESS}...")
try:
# Read Holding Registers (Function Code 03)
response = client.read_holding_registers(address=REGISTER_ADDRESS, count=REGISTER_COUNT)
if not response.isError():
# Access the list of register values
register_values = response.registers
# Ensure we have the expected number of registers
if len(register_values) >= REGISTER_COUNT:
register_values = register_values[:REGISTER_COUNT]
logging.info(f"Successfully read {len(register_values)} register values.")
results = register_values
read_success = True
# Print the results to the console
print("\n--- Read Holding Register Values ---")
for i, value in enumerate(register_values):
print(f"Register {REGISTER_ADDRESS + i}: {value}")
print("----------------------------------\n")
else:
logging.error(f"Read failed: Expected {REGISTER_COUNT} registers, but received {len(register_values)}.")
logging.debug(f"Response: {response}")
else:
# Handle Modbus logical errors
error_code = getattr(response, 'exception_code', None)
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error reading registers: Code {error_code} - {error_message}. Response: {response}")
except ConnectionException as ce:
# Handle connection errors during read
logging.error(f"Connection error during read: {ce}")
except Exception as e:
# Handle other unexpected errors during read attempt
logging.error(f"An unexpected error occurred during read: {e}")
else:
logging.error(f"Failed to connect to the Modbus TCP server at {args.ip_address}:{MODBUS_PORT}.")
except Exception as e:
# Catch errors during initial connection or loop setup
logging.error(f"An unexpected error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed.")
# Write results to JSON file only if read was successful
if read_success and results is not None:
try:
with open(OUTPUT_FILE, 'w') as f:
json.dump(results, f, indent=4)
logging.info(f"Register results successfully written to {OUTPUT_FILE}")
except IOError as e:
logging.error(f"Failed to write results to {OUTPUT_FILE}: {e}")
else:
if connection_success and not read_success:
logging.warning("Read operation failed, JSON file not written.")
elif not connection_success:
logging.warning("Connection failed, JSON file not written.")
# Exit with error code if connection or read failed
if not connection_success or not read_success:
exit(1) # Indicate failure
+92
View File
@@ -0,0 +1,92 @@
import argparse
import os
import logging
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
LOG_LEVEL = logging.INFO
# --- Modbus Exception Code Mapping ---
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
7: "Negative Acknowledge",
8: "Memory Parity Error",
10: "Gateway Path Unavailable",
11: "Gateway Target Device Failed to Respond",
}
# --- Setup Logging ---
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Connect to Modbus TCP server and write a single coil.')
parser.add_argument('--ip-address', type=str, default='192.168.1.250', \
help='IP address of the Modbus TCP server, defaults to 192.168.1.250')
parser.add_argument('--address', type=int, required=True, \
help='Coil address to write.')
parser.add_argument('--value', type=int, required=True, choices=[0, 1], \
help='Value to write to the coil (0 for OFF, 1 for ON).')
args = parser.parse_args()
# --- Main Script ---
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
connection_success = False
write_success = False
try:
logging.info(f"Attempting to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful.")
coil_value = bool(args.value)
logging.info(f"Writing coil {args.address} to value {coil_value} ({args.value})...")
try:
# Write Single Coil (Function Code 05)
response = client.write_coil(address=args.address, value=coil_value)
# --- Add extra debugging ---
logging.debug(f"Raw write response: {response}")
if response is None:
logging.error("Modbus write coil received None response (timeout or connection issue?)")
# --- End extra debugging ---
if response is not None and not response.isError():
logging.info(f"Successfully wrote coil {args.address} to {coil_value}.")
write_success = True
# Print confirmation
print(f"\n--- Wrote Coil {args.address} = {coil_value} ---\n")
else:
# Handle Modbus logical errors
error_code = getattr(response, 'exception_code', None)
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error writing coil: Code {error_code} - {error_message}. Response: {response}")
except ConnectionException as ce:
logging.error(f"Connection error during write: {ce}")
except Exception as e:
logging.error(f"An unexpected error occurred during write: {e}")
else:
logging.error(f"Failed to connect to the Modbus TCP server at {args.ip_address}:{MODBUS_PORT}.")
except Exception as e:
logging.error(f"An unexpected error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed.")
# Exit with error code if connection or write failed
if not connection_success or not write_success:
exit(1) # Indicate failure
+103
View File
@@ -0,0 +1,103 @@
import argparse
import os
import logging
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
LOG_LEVEL = logging.INFO
# --- Modbus Exception Code Mapping ---
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
7: "Negative Acknowledge",
8: "Memory Parity Error",
10: "Gateway Path Unavailable",
11: "Gateway Target Device Failed to Respond",
}
# --- Setup Logging ---
logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Connect to Modbus TCP server and write a single holding register.')
parser.add_argument('--ip-address', type=str, default='192.168.1.250', \
help='IP address of the Modbus TCP server, defaults to 192.168.1.250')
parser.add_argument('--address', type=int, required=True, \
help='Holding register address to write.')
# Make --value optional, but capture remaining args
parser.add_argument('--value', type=int, required=False, \
help='Value to write to the register (integer).')
parser.add_argument('remaining_args', nargs=argparse.REMAINDER)
args = parser.parse_args()
# --- Determine the value to write ---
register_value = None
if args.value is not None:
register_value = args.value
elif len(args.remaining_args) == 1:
# If --value wasn't provided, but exactly one remaining arg exists, assume it's the value
try:
register_value = int(args.remaining_args[0])
logging.debug(f"Using positional argument {register_value} as the value.")
except ValueError:
parser.error(f"Invalid integer value provided: {args.remaining_args[0]}")
else:
# If --value is missing and remaining_args doesn't have exactly one item
parser.error("The --value argument is required (or provide a single integer value after the address).")
# --- Main Script ---
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
connection_success = False
write_success = False
try:
logging.info(f"Attempting to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful.")
# register_value = args.value # Removed: value determined above
logging.info(f"Writing register {args.address} to value {register_value}...")
try:
# Write Single Register (Function Code 06)
response = client.write_register(address=args.address, value=register_value)
if not response.isError():
logging.info(f"Successfully wrote register {args.address} to {register_value}.")
write_success = True
# Print confirmation
print(f"\n--- Wrote Register {args.address} = {register_value} ---\n")
else:
# Handle Modbus logical errors
error_code = getattr(response, 'exception_code', None)
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error writing register: Code {error_code} - {error_message}. Response: {response}")
except ConnectionException as ce:
logging.error(f"Connection error during write: {ce}")
except Exception as e:
logging.error(f"An unexpected error occurred during write: {e}")
else:
logging.error(f"Failed to connect to the Modbus TCP server at {args.ip_address}:{MODBUS_PORT}.")
except Exception as e:
logging.error(f"An unexpected error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed.")
# Exit with error code if connection or write failed
if not connection_success or not write_success:
exit(1) # Indicate failure
+405
View File
@@ -0,0 +1,405 @@
#!/usr/bin/env python
import serial
import argparse
import sys
import time
import signal
import threading
import select
import os
import datetime
import serial.tools.list_ports
import re # Import regex module
# ANSI escape codes for colors
COLORS = {
'F': '\033[91m', # Red for Fatal
'E': '\033[91m', # Red for Error
'W': '\033[93m', # Yellow for Warning
'I': '\033[94m', # Blue for Info/Notice
'N': '\033[94m', # Blue for Info/Notice
'T': '\033[96m', # Cyan for Trace
'V': '\033[95m', # Magenta for Verbose
'RST': '\033[0m' # Reset color
}
# Global flag to control threads
running = True
# Global log file handler
log_file = None
# Global variable to store the timestamp of the last printed message
last_print_time = None
# --- Configuration ---
# Try to find the port automatically, default to COM12 if not found
DEFAULT_PORT = "COM12"
BAUD_RATE = 115200 # Updated to 115200 baud to match firmware
STOP_CHARACTER = '\x03' # Ctrl+C
def ensure_tmp_dir():
"""Make sure the tmp directory exists"""
if not os.path.exists('./tmp'):
os.makedirs('./tmp')
print("Created tmp directory for session logs")
def find_esp_port():
ports = serial.tools.list_ports.comports()
for port, desc, hwid in sorted(ports):
# Look for common ESP32 VID/PID or descriptions
# Added more specific PIDs commonly found on ESP32-S3 dev boards
if ("CP210x" in desc or
"USB Serial Device" in desc or
"CH340" in desc or
"SER=Serial" in hwid or
"VID:PID=10C4:EA60" in hwid or # CP210x
"VID:PID=303A:1001" in hwid): # ESP32-S3 built-in USB-CDC
print(f"Found potential ESP device: {port} ({desc}) [{hwid}]")
return port
print(f"Could not automatically find ESP device.")
return None # Return None instead of default port
def open_log_file(port, baudrate, retries):
"""Open a new log file with timestamp in filename"""
ensure_tmp_dir()
timestamp = datetime.datetime.now().strftime('%H_%M_%S')
log_path = f'./tmp/session-{timestamp}.md'
# Create and open the log file
log = open(log_path, 'w', encoding='utf-8')
# Write header
log.write(f"# Serial Communication Session Log\n\n")
log.write(f"Started at: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
log.write(f"## Configuration\n")
log.write(f"- Port: {port}\n")
log.write(f"- Baudrate: {baudrate}\n")
log.write(f"- Retries: {retries}\n\n")
log.write(f"## Communication Log\n\n")
log.flush()
print(f"Logging session to: {log_path}")
return log, log_path
def format_component_names(message):
"""Makes component names bold in the markdown log using regex"""
# Regex to find words starting with a capital letter, possibly followed by more caps/lowercase
# Looks for words after ": " or at the start of the line, common for component names
pattern = r'(?<=:\s)([A-Z][a-zA-Z0-9_]+)|(^[A-Z][a-zA-Z0-9_]+)'
def replace_match(match):
# Group 1 is for matches after ": ", Group 2 is for matches at the start
name = match.group(1) or match.group(2)
# Avoid bolding common single uppercase letters used as prefixes (F, E, W, I, N, T, V)
if name and len(name) > 1:
return f'**{name}**'
return name if name else '' # Return original if no suitable name found
return re.sub(pattern, replace_match, message)
def log_message(message, is_user_input=False):
"""Log a message to the log file"""
global log_file
if log_file and not log_file.closed:
timestamp = datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3]
if is_user_input:
log_file.write(f"**[{timestamp}] USER INPUT >** `{message}`\n\n")
else:
# Format component names before writing
formatted_message = format_component_names(message)
# Remove 'DEVICE >' prefix and write formatted message
log_file.write(f"**[{timestamp}]** {formatted_message}\n\n")
log_file.flush()
def signal_handler(sig, frame):
global running, log_file
print("\nExiting serial monitor...")
running = False
# Close log file if open
if log_file and not log_file.closed:
log_file.write(f"\n## Session End\n\nEnded at: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
log_file.flush()
log_file.close()
print(f"Log file closed.")
# Allow time for threads to exit gracefully
time.sleep(0.5)
sys.exit(0)
def input_thread(ser):
"""Thread to read user input and send it to serial port"""
global running
print("Input mode enabled. Type commands and press Enter to send.")
print("Enter 'exit' or 'quit' to exit the program.")
try:
while running:
# Simple input approach that works on all platforms
user_input = input()
# Skip empty lines
if not user_input.strip():
continue
if user_input.lower() in ('exit', 'quit'):
print("Exit command received.")
log_message(user_input, is_user_input=True)
running = False
break
# Special commands
if user_input.startswith('!'):
log_message(user_input, is_user_input=True)
handle_special_command(user_input, ser)
continue
# Log the user input
log_message(user_input, is_user_input=True)
# Add newline to input if not present
if not user_input.endswith('\n'):
user_input += '\n'
# Send to serial port
print(f"---- Sending: {repr(user_input)} ----")
ser.write(user_input.encode('utf-8'))
ser.flush()
except Exception as e:
print(f"Input thread error: {e}")
finally:
print("Input thread exiting")
def handle_special_command(cmd, ser):
"""Handle special commands that start with '!'"""
# Strip the '!' prefix
cmd = cmd[1:].strip()
if cmd.startswith('send_hex '):
# Format: !send_hex 01 02 03 FF
try:
hex_values = cmd[9:].split()
bytes_to_send = bytes([int(x, 16) for x in hex_values])
print(f"Sending hex bytes: {' '.join(hex_values)}")
ser.write(bytes_to_send)
ser.flush()
except Exception as e:
print(f"Error sending hex: {e}")
elif cmd.startswith('baudrate '):
# Format: !baudrate 115200
try:
new_baudrate = int(cmd[9:])
print(f"Changing baudrate to {new_baudrate}")
ser.baudrate = new_baudrate
except Exception as e:
print(f"Error changing baudrate: {e}")
elif cmd == 'help':
print("\nSpecial commands:")
print(" !send_hex XX XX XX - Send hex bytes")
print(" !baudrate XXXX - Change baudrate")
print(" !help - Show this help\n")
else:
print(f"Unknown special command: {cmd}")
print("Type !help for available commands")
def print_colored_output(line):
"""Prints the line to console, adding color to the log level prefix if found and prepending time delta."""
global last_print_time
current_time = datetime.datetime.now()
time_delta_str = ""
if last_print_time:
delta = current_time - last_print_time
# Calculate minutes, seconds, milliseconds
total_seconds = delta.total_seconds()
minutes = int(total_seconds // 60)
seconds = int(total_seconds % 60)
milliseconds = delta.microseconds // 1000
# Format as mm:ss:fff
time_delta_str = "{:02}:{:02}:{:03} ".format(minutes, seconds, milliseconds)
if len(line) > 1 and line[1] == ':' and line[0] in COLORS:
level_char = line[0]
color_code = COLORS.get(level_char, '') # Get color for the level
rest_of_line = line[1:]
print(f"{time_delta_str}{color_code}{level_char}{COLORS['RST']}{rest_of_line}")
else:
# Print normally if no recognized log level prefix, but still include time delta
print(f"{time_delta_str}{line}")
last_print_time = current_time
def monitor_serial(port, baudrate, max_retries=3, initial_command=None, exit_after_command=False):
global running, log_file
retry_count = 0
while retry_count < max_retries and running:
try:
print(f"Attempt {retry_count + 1}/{max_retries}: Opening serial port {port} at {baudrate} baud (8N1)...")
ser = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=0.1
)
print(f"Serial port {port} opened successfully. Monitoring for messages...")
print("Type commands and press Enter to send them to the device.")
print("Special commands start with '!' (Type !help for info)")
print("Press Ctrl+C to exit")
print("-" * 60)
# Start input thread for sending commands
input_handler = threading.Thread(target=input_thread, args=(ser,))
input_handler.daemon = True
input_handler.start()
# Send initial command if provided
if initial_command:
print(f"Waiting 4 second before sending command...")
time.sleep(4) # Ensure at least 1000ms delay
print(f"Sending initial command: {initial_command}")
if not initial_command.endswith('\n'):
initial_command += '\n'
try:
ser.write(initial_command.encode('utf-8'))
ser.flush()
time.sleep(0.2) # Small delay after sending command
except serial.SerialException as e:
print(f"Error sending initial command: {e}")
except Exception as e:
print(f"An unexpected error occurred sending initial command: {e}")
# Check if we should exit after sending the command
if exit_after_command:
print("Command sent. Waiting 3 seconds for response before exiting...")
log_message("Exiting after sending initial command.")
# Read for 3 seconds before exiting
exit_start_time = time.time()
while time.time() - exit_start_time < 3.0:
if ser.in_waiting > 0:
try:
line = ser.readline()
if line:
try:
decoded = line.decode('utf-8').rstrip()
print(decoded) # Print response received during wait
log_message(decoded)
except UnicodeDecodeError:
hex_str = ' '.join([f'{b:02x}' for b in line])
print(f"HEX: {hex_str}")
log_message(f"HEX: {hex_str}")
except Exception as e:
print(f"Error reading during exit wait: {e}")
log_message(f"ERROR during exit wait: {str(e)}")
time.sleep(0.01) # Short sleep to prevent busy-waiting
running = False # Signal threads to stop AFTER the wait/read period
# The main loop below will be skipped, and finally block will close port
# Main loop for reading from serial
while running:
if ser.in_waiting > 0:
try:
line = ser.readline()
if line: # Only process non-empty lines
try:
# Try to decode as UTF-8 first
decoded = line.decode('utf-8').rstrip()
# Print with color formatting
print_colored_output(decoded)
# Log the response (without color codes)
log_message(decoded)
except UnicodeDecodeError:
# If that fails, print hex values
hex_str = ' '.join([f'{b:02x}' for b in line])
print(f"HEX: {hex_str}")
# Log the hex response
log_message(f"HEX: {hex_str}")
except Exception as e:
print(f"Error reading from serial: {e}")
log_message(f"ERROR: {str(e)}")
else:
# Small sleep to prevent CPU hogging
time.sleep(0.01)
except serial.SerialException as e:
retry_count += 1
error_msg = f"Connection failed: {e}"
print(error_msg)
log_message(error_msg)
if retry_count >= max_retries or not running:
final_error = f"Error opening serial port after {retry_count} attempts: {e}"
print(final_error)
log_message(final_error)
running = False
sys.exit(1)
else:
retry_msg = f"Retrying in 2 seconds... (Attempt {retry_count + 1}/{max_retries})"
print(retry_msg)
log_message(retry_msg)
time.sleep(2) # Wait before retrying
except KeyboardInterrupt:
print("\nMonitor stopped by user")
log_message("Monitor stopped by user")
running = False
finally:
# This will only execute if we break out of the inner while loop
if 'ser' in locals() and ser.is_open:
ser.close()
close_msg = f"Serial port {port} closed"
print(close_msg)
log_message(close_msg)
def main():
global running, log_file, args
# Register the signal handler for a clean exit with Ctrl+C
signal.signal(signal.SIGINT, signal_handler)
parser = argparse.ArgumentParser(description='Serial monitor with optional initial command.')
parser.add_argument('port', nargs='?', default=None, help='Serial port name (e.g., COM3 or /dev/ttyUSB0). If omitted, attempts to find ESP device.')
parser.add_argument('-c', '--command', type=str, default=None, help='Initial command to send upon connection.')
parser.add_argument('--baudrate', '-b', type=int, default=BAUD_RATE, help='Baudrate (default: 115200)')
parser.add_argument('--retries', '-r', type=int, default=3, help='Number of connection retry attempts (default: 3)')
parser.add_argument('-x', '--exit-after-command', action='store_true', help='Exit immediately after sending the initial command specified with -c.')
args = parser.parse_args()
# Determine port
serial_port = args.port
if not serial_port:
print("No port specified, attempting to find ESP device...")
serial_port = find_esp_port()
if not serial_port:
print(f"Could not find ESP device. Please specify the port manually. Exiting.")
sys.exit(1)
else:
print(f"Using specified port: {serial_port}")
# Open log file
log_file, log_path = open_log_file(serial_port, args.baudrate, args.retries)
running = True
try:
monitor_serial(serial_port, args.baudrate, args.retries, args.command, args.exit_after_command)
except Exception as e:
print(f"Unexpected error: {e}")
finally:
running = False
if log_file:
log_file.close()
print(f"Log saved to: {log_path}")
print("Serial monitor stopped.")
if __name__ == "__main__":
main()
+456
View File
@@ -0,0 +1,456 @@
#!/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()
+260
View File
@@ -0,0 +1,260 @@
# scripts/rate_test_serial.py
import serial
import time
import sys
import argparse
import serial.tools.list_ports
import json
import os
from datetime import datetime
# --- Configuration ---
DEFAULT_PORT = "COM12"
BAUD_RATE = 115200
INIT_DELAY = 2.5 # Match original script's 2.5s delay after connection
# 125cmds/sec - 8ms delay
def find_esp_port():
"""Tries to automatically find the ESP device port."""
ports = serial.tools.list_ports.comports()
for port, desc, hwid in sorted(ports):
# Look for common ESP32 VID/PID or descriptions
if "CP210x" in desc or "USB Serial Device" in desc or "CH340" in desc or "SER=Serial" in hwid or "VID:PID=10C4:EA60" in hwid:
print(f"Found potential ESP device: {port} ({desc})")
return port
print(f"Could not automatically find ESP device, defaulting to {DEFAULT_PORT}")
return DEFAULT_PORT
def test_command_rate(port, command, min_delay_ms, max_delay_ms, step_ms, num_commands, report_file=None):
"""Tests sending commands at different rates to find the maximum reliable rate."""
ser = None
current_delay = max_delay_ms # Start with the slowest rate (most likely to work)
# Prepare report data
report_data = {
"timestamp": datetime.now().isoformat(),
"command": command,
"port": port,
"baud_rate": BAUD_RATE,
"parameters": {
"min_delay_ms": min_delay_ms,
"max_delay_ms": max_delay_ms,
"step_ms": step_ms,
"commands_per_test": num_commands
},
"tests": [],
"result": {
"max_reliable_rate_cmds_per_sec": None,
"min_reliable_delay_ms": None,
"success": False
}
}
try:
print(f"Opening connection to {port} at {BAUD_RATE} baud...")
ser = serial.Serial(port, BAUD_RATE, timeout=1)
print(f"Connected. Waiting {INIT_DELAY}s for initialization...")
time.sleep(INIT_DELAY) # Initial delay
ser.reset_input_buffer()
# First test with maximum delay to establish baseline
print(f"\n=== Testing with {current_delay}ms delay (baseline) ===")
success, test_data = run_command_sequence(ser, command, current_delay, num_commands)
report_data["tests"].append({
"delay_ms": current_delay,
"success": success,
"data": test_data
})
if not success:
print("Failed even with maximum delay! Device might not be responding correctly.")
return report_data
# Binary search to find the threshold
low = min_delay_ms
high = max_delay_ms
last_success_delay = max_delay_ms
while high - low > step_ms:
mid = (low + high) // 2
print(f"\n=== Testing with {mid}ms delay ===")
success, test_data = run_command_sequence(ser, command, mid, num_commands)
report_data["tests"].append({
"delay_ms": mid,
"success": success,
"data": test_data
})
if success:
high = mid # Try with a shorter delay
last_success_delay = mid
else:
low = mid # Need a longer delay
# Final confirmation of the threshold
print(f"\n=== FINAL TEST with {last_success_delay}ms delay ===")
success, test_data = run_command_sequence(ser, command, last_success_delay, num_commands)
report_data["tests"].append({
"delay_ms": last_success_delay,
"success": success,
"data": test_data,
"is_final_test": True
})
if success:
rate = 1000.0 / last_success_delay
print(f"\n✅ Maximum reliable rate: approx. {rate:.2f} commands per second ({last_success_delay}ms delay)")
report_data["result"]["success"] = True
report_data["result"]["max_reliable_rate_cmds_per_sec"] = rate
report_data["result"]["min_reliable_delay_ms"] = last_success_delay
else:
print(f"\n⚠️ Results inconsistent. Try again with different parameters.")
report_data["result"]["success"] = False
except Exception as e:
print(f"Error: {e}")
report_data["error"] = str(e)
finally:
if ser and ser.is_open:
print("Closing serial port.")
ser.close()
# Save report if requested
if report_file:
# Ensure directory exists
os.makedirs(os.path.dirname(os.path.abspath(report_file)), exist_ok=True)
with open(report_file, 'w') as f:
json.dump(report_data, f, indent=2)
print(f"Report saved to {report_file}")
return report_data
def run_command_sequence(ser, command, delay_ms, count):
"""Runs a sequence of commands with specified delay between them."""
expected_responses = ["V: Bridge::onMessage", "V: PHApp::list", "V: Called method:"]
all_success = True
test_data = []
for i in range(count):
print(f"Sending command #{i+1}/{count} with {delay_ms}ms delay")
success, response_data = send_and_verify(ser, command, expected_responses)
test_data.append({
"command_index": i+1,
"success": success,
"responses": response_data
})
all_success = all_success and success
if not success:
print(f"❌ Failed at attempt #{i+1} - stopping sequence")
return False, test_data
if i < count - 1: # Don't sleep after the last command
time.sleep(delay_ms / 1000.0)
return all_success, test_data
def send_and_verify(ser, command, expected_responses):
"""Sends a command and verifies the response contains expected strings."""
# Send command
print(f" Sending: {command}")
send_time = time.time()
if not command.endswith('\n'):
command += '\n'
ser.write(command.encode('utf-8'))
ser.flush()
# Read response with timeout
start = time.time()
timeout = 2.0 # 2 seconds to receive a response
buffer = ""
found_responses = [False] * len(expected_responses)
response_data = {
"send_time": send_time,
"response_time": None,
"total_response_time_ms": None,
"lines": [],
"expected_responses": {response: False for response in expected_responses},
"all_found": False,
"echo_detected": False
}
while time.time() - start < timeout:
if ser.in_waiting > 0:
data = ser.readline().decode('utf-8', errors='ignore').strip()
if data:
receive_time = time.time()
print(f" Received: {data}")
buffer += data + "\n"
response_data["lines"].append({
"text": data,
"time": receive_time,
"delay_ms": round((receive_time - send_time) * 1000, 2)
})
# First response time
if response_data["response_time"] is None:
response_data["response_time"] = receive_time
response_data["total_response_time_ms"] = round((receive_time - send_time) * 1000, 2)
# Check if this line contains any of our expected responses
for i, expected in enumerate(expected_responses):
if expected in data:
found_responses[i] = True
response_data["expected_responses"][expected] = True
# If we've found all expected responses, we can stop early
if all(found_responses):
print(" ✅ All expected responses received")
response_data["all_found"] = True
return True, response_data
else:
time.sleep(0.01) # Short sleep to avoid busy waiting
# Check if we've seen all expected responses
all_found = all(found_responses)
response_data["all_found"] = all_found
if not all_found:
print(" ❌ Not all expected responses received")
for i, expected in enumerate(expected_responses):
if not found_responses[i]:
print(f" Missing: {expected}")
# Look for echo pattern
if command.strip('\n') in buffer:
print(" ⚠️ Command echo detected - device might be in echo mode or not handling commands")
response_data["echo_detected"] = True
return all_found, response_data
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Test maximum command rate for ESP device.")
parser.add_argument("command", help="The command to send (e.g., '<<1;2;64;list:1:0>>')")
parser.add_argument("--port", help=f"Serial port (default: auto-detect or {DEFAULT_PORT})")
parser.add_argument("--min-delay", type=int, default=5, help="Minimum delay between commands (ms)")
parser.add_argument("--max-delay", type=int, default=1000, help="Maximum delay between commands (ms)")
parser.add_argument("--step", type=int, default=5, help="Step size for delay adjustment (ms)")
parser.add_argument("--count", type=int, default=3, help="Number of commands to send in each test sequence")
parser.add_argument("--report", help="Path to save JSON report (default: ./tmp/battle.json)")
args = parser.parse_args()
# Determine port
serial_port = args.port if args.port else find_esp_port()
# Set default report path if not specified
report_path = args.report if args.report else "./tmp/battle.json"
# Run the rate test
test_command_rate(
serial_port,
args.command,
args.min_delay,
args.max_delay,
args.step,
args.count,
report_path
)
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Read Battle Counter - Simple script to read the battle counter from the ESP32
"""
import argparse
import json
import os
import logging
import time
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
MODBUS_PORT = 502
MB_BATTLE_COUNTER_REG = 20 # Counter register address
MB_BATTLE_TIMESTAMP_REG = 21 # Timestamp register address
OUTPUT_DIR = "tmp"
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "battle-counter.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='Read the battle counter value from the ESP32')
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('--increment', action='store_true',
help='Increment the counter after reading it')
parser.add_argument('--reset', action='store_true',
help='Reset the counter to 0')
parser.add_argument('--watch', action='store_true',
help='Watch the counter continuously')
parser.add_argument('--interval', type=float, default=1.0,
help='Interval in seconds between watch updates (default: 1.0)')
args = parser.parse_args()
# Create output directory
os.makedirs(OUTPUT_DIR, exist_ok=True)
def read_counter(client):
"""Read the battle counter and timestamp from the ESP32"""
try:
# Read the counter value at register 20
response = client.read_holding_registers(address=MB_BATTLE_COUNTER_REG, count=2)
if not response.isError() and len(response.registers) >= 2:
counter = response.registers[0]
timestamp = response.registers[1]
return counter, timestamp
else:
logging.error(f"Error reading counter: {response}")
return None, None
except Exception as e:
logging.error(f"Exception reading counter: {e}")
return None, None
def increment_counter(client):
"""Increment the battle counter on the ESP32"""
try:
# Read current value
counter, _ = read_counter(client)
if counter is not None:
# Increment by 1
response = client.write_register(address=MB_BATTLE_COUNTER_REG, value=counter+1)
if not response.isError():
logging.info(f"Counter incremented from {counter} to {counter+1}")
return True
else:
logging.error(f"Error incrementing counter: {response}")
return False
return False
except Exception as e:
logging.error(f"Exception incrementing counter: {e}")
return False
def reset_counter(client):
"""Reset the battle counter to 0"""
try:
response = client.write_register(address=MB_BATTLE_COUNTER_REG, value=0)
if not response.isError():
logging.info("Counter reset to 0")
return True
else:
logging.error(f"Error resetting counter: {response}")
return False
except Exception as e:
logging.error(f"Exception resetting counter: {e}")
return False
def watch_counter(client, interval):
"""Watch the counter continuously"""
last_counter = None
try:
print("\nWatching battle counter (Ctrl+C to stop)...")
print("------------------------------------------------")
print("| Counter | Timestamp | Changes/sec | Changes |")
print("------------------------------------------------")
start_time = time.time()
start_counter = None
while True:
counter, timestamp = read_counter(client)
if counter is not None:
if start_counter is None:
start_counter = counter
elapsed = time.time() - start_time
changes = counter - start_counter if start_counter is not None else 0
rate = changes / elapsed if elapsed > 0 else 0
# Only print if the counter has changed
if last_counter != counter:
print(f"| {counter:7d} | {timestamp:9d} | {rate:11.2f} | {changes:7d} |")
last_counter = counter
time.sleep(interval)
except KeyboardInterrupt:
print("\nStopped watching counter.")
def main():
client = ModbusTcpClient(args.ip_address, port=MODBUS_PORT)
try:
logging.info(f"Connecting to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}...")
connection_success = client.connect()
if connection_success:
logging.info("Connection successful")
if args.reset:
reset_counter(client)
time.sleep(0.1) # Small delay
if args.watch:
watch_counter(client, args.interval)
else:
counter, timestamp = read_counter(client)
if counter is not None:
print(f"\nBattle Counter: {counter}")
print(f"Timestamp: {timestamp}")
# Save to JSON file
with open(OUTPUT_FILE, 'w') as f:
json.dump({
"timestamp": time.time(),
"counter": counter,
"modbus_timestamp": timestamp
}, f, indent=2)
logging.info(f"Counter value saved to {OUTPUT_FILE}")
if args.increment:
increment_counter(client)
time.sleep(0.1) # Small delay
counter, timestamp = read_counter(client)
if counter is not None:
print(f"\nAfter increment:")
print(f"Battle Counter: {counter}")
print(f"Timestamp: {timestamp}")
else:
logging.error(f"Failed to connect to Modbus TCP server at {args.ip_address}:{MODBUS_PORT}")
except Exception as e:
logging.error(f"An error occurred: {e}")
finally:
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed")
if __name__ == "__main__":
main()
+6
View File
@@ -0,0 +1,6 @@
requests>=2.28.0
colorama>=0.4.4
jinja2>=3.0.0
pyyaml>=6.0
pyserial>=3.5
# Add other Python dependencies needed by scripts below
+87
View File
@@ -0,0 +1,87 @@
import serial
import time
import sys
import serial.tools.list_ports
# --- Configuration ---
# Try to find the port automatically, default to COM12 if not found
DEFAULT_PORT = "COM12"
BAUD_RATE = 115200 # Updated to 115200 baud to match successful run
RESPONSE_READ_TIMEOUT = 5 # Seconds to wait for response lines
def find_esp_port():
ports = serial.tools.list_ports.comports()
for port, desc, hwid in sorted(ports):
# Look for common ESP32 VID/PID or descriptions
if "CP210x" in desc or "USB Serial Device" in desc or "CH340" in desc or "SER=Serial" in hwid or "VID:PID=10C4:EA60" in hwid:
print(f"Found potential ESP device: {port} ({desc})")
return port
print(f"Could not automatically find ESP device, defaulting to {DEFAULT_PORT}")
return DEFAULT_PORT
def send_receive(port, baud, command_to_send, read_timeout):
"""Connects, sends a command, reads the response, and closes."""
ser = None # Initialize ser to None
try:
print(f"Attempting to connect to {port} at {baud} baud...")
ser = serial.Serial(port, baud, timeout=1) # Initial timeout for connection
print("Connected. Waiting for board to initialize...")
time.sleep(2.5) # Give time for potential reset after connect
ser.reset_input_buffer() # Clear any initial garbage data
print(f"Sending command: {command_to_send}")
if not command_to_send.endswith('\n'):
command_to_send += '\n'
ser.write(command_to_send.encode('utf-8'))
ser.flush() # Wait for data to be written
print("Command sent. Waiting for response...")
# Read response
lines = []
start_time = time.time()
while time.time() - start_time < read_timeout:
if ser.in_waiting > 0:
try:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(f"Received: {line}")
lines.append(line)
except Exception as read_err:
print(f"Error reading line: {read_err}")
# Continue trying to read other lines
else:
time.sleep(0.05) # Avoid busy-waiting
if not lines:
print("No response received within the timeout.")
return "\n".join(lines)
except serial.SerialException as e:
print(f"Serial Error: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
finally:
if ser and ser.is_open:
print("Closing serial port.")
ser.close()
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python send_serial_cmd.py "<command_to_send>" [port]")
sys.exit(1)
command = sys.argv[1]
# Use provided port or find automatically
serial_port = DEFAULT_PORT
if len(sys.argv) > 2:
serial_port = sys.argv[2]
print(f"Using specified port: {serial_port}")
else:
serial_port = find_esp_port()
send_receive(serial_port, BAUD_RATE, command, RESPONSE_READ_TIMEOUT)
+131
View File
@@ -0,0 +1,131 @@
# scripts/stress_test_serial.py
import serial
import time
import sys
import serial.tools.list_ports
import argparse
# --- Configuration ---
DEFAULT_PORT = "COM12"
BAUD_RATE = 115200
RESPONSE_READ_TIMEOUT = 2 # Seconds to wait for response lines (reduced for faster testing)
CONNECT_DELAY = 2.0 # Seconds to wait after connecting
# 125cmds/sec - 8ms delay
def find_esp_port():
"""Tries to automatically find the ESP device port."""
ports = serial.tools.list_ports.comports()
for port, desc, hwid in sorted(ports):
# Look for common ESP32 VID/PID or descriptions
if "CP210x" in desc or "USB Serial Device" in desc or "CH340" in desc or "SER=Serial" in hwid or "VID:PID=10C4:EA60" in hwid:
print(f"Found potential ESP device: {port} ({desc})")
return port
print(f"Could not automatically find ESP device, defaulting to {DEFAULT_PORT}")
return DEFAULT_PORT
def send_receive(port, baud, command_to_send, read_timeout, connect_delay):
"""Connects, sends a single command, reads the response, and closes."""
ser = None
all_lines = []
print("-" * 20)
try:
# print(f"Attempting to connect to {port} at {baud} baud...")
ser = serial.Serial(port, baud, timeout=1)
# print(f"Connected. Waiting {connect_delay}s for board...")
time.sleep(connect_delay)
ser.reset_input_buffer()
print(f"Sending command: {command_to_send}")
if not command_to_send.endswith('\\n'):
command_to_send += '\\n'
ser.write(command_to_send.encode('utf-8'))
ser.flush()
# print("Command sent. Waiting for response...")
# Read response
start_time = time.time()
while time.time() - start_time < read_timeout:
if ser.in_waiting > 0:
try:
line = ser.readline().decode('utf-8', errors='ignore').strip()
if line:
print(f"Received: {line}")
all_lines.append(line)
# Reset start time if we get data, maybe? Or just fixed timeout?
# start_time = time.time() # Uncomment to reset timeout on receiving data
except Exception as read_err:
print(f"Error reading line: {read_err}")
else:
# Only sleep if nothing is waiting
time.sleep(0.02) # Short sleep to avoid busy-waiting
if not all_lines:
print("No response received within the timeout.")
return "\\n".join(all_lines)
except serial.SerialException as e:
print(f"Serial Error: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred: {e}")
return None
finally:
if ser and ser.is_open:
# print("Closing serial port.")
ser.close()
print("-" * 20)
def run_malformed_tests(port, baud, read_timeout, connect_delay):
"""Runs a predefined sequence of malformed tests."""
print("--- Running Malformed Command Tests ---")
malformed_commands = [
"<<1;2;64;list:1:0", # Missing end bracket
"1;2;64;list:1:0>>", # Missing start bracket
"<<1;2;64>>", # Missing payload section
"<<1;2;64;list:1:0", # Missing final >
"<<1;2;64;list:1:", # Incomplete payload
"<<abc;def;ghi;list:1:0>>", # Non-numeric header parts
"<<1;2;64;very_long_payload_string_that_might_exceed_buffers_if_not_handled_well_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx>>", # Long payload
"<<>>", # Empty
"", # Totally empty string
"Just some random text", # Not matching format
"<<1;2;64;list:1:0>><<1;2;64;list:1:0>>", # Two commands concatenated
]
for cmd in malformed_commands:
send_receive(port, baud, cmd, read_timeout, connect_delay)
time.sleep(0.2) # Small delay between malformed tests
print("--- Malformed Command Tests Finished ---")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Send serial commands to ESP device, with stress testing options.")
parser.add_argument("command", nargs='?', default=None, help="The command string to send (e.g., '<<1;2;64;list:1:0>>'). Required unless --malformed is used.")
parser.add_argument("-p", "--port", default=None, help=f"Serial port name. Defaults to auto-detect or {DEFAULT_PORT}.")
parser.add_argument("-n", "--count", type=int, default=1, help="Number of times to send the command.")
parser.add_argument("-d", "--delay", type=int, default=50, help="Delay in milliseconds between sending commands.")
parser.add_argument("--malformed", action="store_true", help="Run a sequence of malformed command tests instead of sending the specified command.")
parser.add_argument("--timeout", type=float, default=RESPONSE_READ_TIMEOUT, help="Timeout in seconds to wait for response lines.")
parser.add_argument("--connect-delay", type=float, default=CONNECT_DELAY, help="Delay in seconds after connecting before sending.")
args = parser.parse_args()
if not args.malformed and args.command is None:
parser.error("the following arguments are required: command (unless --malformed is specified)")
# Determine port
serial_port = args.port if args.port else find_esp_port()
if args.malformed:
run_malformed_tests(serial_port, BAUD_RATE, args.timeout, args.connect_delay)
else:
print(f"--- Sending command '{args.command}' {args.count} times with {args.delay}ms delay ---")
for i in range(args.count):
print(f"Sending command #{i+1}/{args.count}")
send_receive(serial_port, BAUD_RATE, args.command, args.timeout, args.connect_delay)
if args.count > 1 and i < args.count - 1:
time.sleep(args.delay / 1000.0)
print(f"--- Finished sending command {args.count} times ---")
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""
Test script for the ESP32 Modbus REST API
This script tests all endpoints of the REST API and reports the results.
"""
import requests
import json
import argparse
import time
import sys
from colorama import init, Fore, Style
# Initialize colorama for colored terminal output
init()
class ApiTester:
def __init__(self, base_url):
self.base_url = base_url
self.api_url = f"{base_url}/api/v1"
self.success_count = 0
self.fail_count = 0
def print_success(self, message):
print(f"{Fore.GREEN}✓ SUCCESS: {message}{Style.RESET_ALL}")
self.success_count += 1
def print_fail(self, message, error=None):
print(f"{Fore.RED}✗ FAIL: {message}{Style.RESET_ALL}")
if error:
print(f" {Fore.YELLOW}Error: {error}{Style.RESET_ALL}")
self.fail_count += 1
def print_info(self, message):
print(f"{Fore.BLUE} INFO: {message}{Style.RESET_ALL}")
def print_response(self, response):
try:
formatted_json = json.dumps(response.json(), indent=2)
print(f"{Fore.CYAN}Response: {formatted_json}{Style.RESET_ALL}")
except:
print(f"{Fore.CYAN}Response: {response.text}{Style.RESET_ALL}")
def run_tests(self):
"""Run all API tests"""
self.print_info(f"Testing API at {self.api_url}")
print("=" * 80)
# Test system info endpoint
self.test_system_info()
print("-" * 80)
# Test coil endpoints
self.test_coils_list()
print("-" * 80)
self.test_coil_get(30) # Test relay coil 0
print("-" * 80)
self.test_coil_toggle(30) # Toggle relay coil 0
print("-" * 80)
# Test register endpoints
self.test_registers_list()
print("-" * 80)
self.test_register_get(20) # Test battle counter register
print("-" * 80)
self.test_register_update(20, 42) # Update battle counter register
print("-" * 80)
# Test relay test endpoint
self.test_relay_test()
print("=" * 80)
# Print summary
print(f"\nTest Summary: {self.success_count} passed, {self.fail_count} failed")
return self.fail_count == 0
def test_system_info(self):
"""Test the system info endpoint"""
self.print_info("Testing GET /system/info")
try:
response = requests.get(f"{self.api_url}/system/info", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'version' in data and 'board' in data and 'uptime' in data:
self.print_success("System info endpoint returned valid data")
else:
self.print_fail("System info endpoint returned incomplete data")
else:
self.print_fail(f"System info endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to system info endpoint", str(e))
def test_coils_list(self):
"""Test the coils list endpoint"""
self.print_info("Testing GET /coils")
try:
response = requests.get(f"{self.api_url}/coils?start=0&count=10", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'coils' in data and isinstance(data['coils'], list):
self.print_success("Coils endpoint returned valid data")
else:
self.print_fail("Coils endpoint returned invalid data")
else:
self.print_fail(f"Coils endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to coils endpoint", str(e))
def test_coil_get(self, address):
"""Test getting a specific coil"""
self.print_info(f"Testing GET /coils?address={address}")
try:
response = requests.get(f"{self.api_url}/coils", params={"address": address}, timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'address' in data and 'value' in data:
self.print_success(f"Coil {address} endpoint returned valid data")
else:
self.print_fail(f"Coil {address} endpoint returned incomplete data")
else:
self.print_fail(f"Coil {address} endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail(f"Failed to connect to coil {address} endpoint", str(e))
def test_coil_toggle(self, address):
"""Test toggling a coil"""
self.print_info(f"Testing POST /coils/{address}")
# First, get the current value
try:
get_response = requests.get(f"{self.api_url}/coils", params={"address": address}, timeout=5)
if get_response.status_code == 200:
current_value = get_response.json().get('value', False)
new_value = not current_value
# Now toggle the value
try:
self.print_info(f"Setting coil {address} to {new_value}")
post_response = requests.post(
f"{self.api_url}/coils/{address}",
json={"value": new_value},
timeout=5
)
if post_response.status_code == 200:
data = post_response.json()
self.print_response(post_response)
if ('success' in data and data['success'] and
'address' in data and data['address'] == address and
'value' in data and data['value'] == new_value):
self.print_success(f"Successfully toggled coil {address}")
# Toggle back to original state to be nice
time.sleep(1)
requests.post(
f"{self.api_url}/coils/{address}",
json={"value": current_value},
timeout=5
)
self.print_info(f"Reset coil {address} to original state")
else:
self.print_fail(f"Failed to toggle coil {address}")
else:
self.print_fail(f"Coil toggle endpoint returned status code {post_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to toggle coil {address}", str(e))
else:
self.print_fail(f"Failed to get current coil state: {get_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to get current coil state", str(e))
def test_registers_list(self):
"""Test the registers list endpoint"""
self.print_info("Testing GET /registers")
try:
response = requests.get(f"{self.api_url}/registers?start=0&count=10", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'registers' in data and isinstance(data['registers'], list):
self.print_success("Registers endpoint returned valid data")
else:
self.print_fail("Registers endpoint returned invalid data")
else:
self.print_fail(f"Registers endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to registers endpoint", str(e))
def test_register_get(self, address):
"""Test getting a specific register"""
self.print_info(f"Testing GET /registers?address={address}")
try:
response = requests.get(f"{self.api_url}/registers", params={"address": address}, timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'address' in data and 'value' in data:
self.print_success(f"Register {address} endpoint returned valid data")
else:
self.print_fail(f"Register {address} endpoint returned incomplete data")
else:
self.print_fail(f"Register {address} endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail(f"Failed to connect to register {address} endpoint", str(e))
def test_register_update(self, address, new_value):
"""Test updating a register"""
self.print_info(f"Testing POST /registers/{address}")
# First, get the current value
try:
get_response = requests.get(f"{self.api_url}/registers", params={"address": address}, timeout=5)
if get_response.status_code == 200:
current_value = get_response.json().get('value', 0)
# Now update the value
try:
self.print_info(f"Setting register {address} to {new_value}")
post_response = requests.post(
f"{self.api_url}/registers/{address}",
params={"value": new_value},
timeout=5
)
if post_response.status_code == 200:
data = post_response.json()
self.print_response(post_response)
if ('success' in data and data['success'] and
'address' in data and data['address'] == address and
'value' in data and data['value'] == new_value):
self.print_success(f"Successfully updated register {address}")
# Restore original value to be nice
time.sleep(1)
requests.post(
f"{self.api_url}/registers/{address}",
params={"value": current_value},
timeout=5
)
self.print_info(f"Reset register {address} to original value")
else:
self.print_fail(f"Failed to update register {address}")
else:
self.print_fail(f"Register update endpoint returned status code {post_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to update register {address}", str(e))
else:
self.print_fail(f"Failed to get current register value: {get_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to get current register value", str(e))
def test_relay_test(self):
"""Test the relay test endpoint"""
self.print_info("Testing POST /relay/test")
try:
response = requests.post(f"{self.api_url}/relay/test", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'success' in data and 'message' in data:
self.print_success("Relay test endpoint returned valid data")
else:
self.print_fail("Relay test endpoint returned incomplete data")
else:
self.print_fail(f"Relay test endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to relay test endpoint", str(e))
def main():
parser = argparse.ArgumentParser(description='Test the ESP32 Modbus REST API')
parser.add_argument('--host', type=str, default='modbus-esp32.local',
help='Hostname or IP address of the ESP32 device (default: modbus-esp32.local)')
parser.add_argument('--port', type=int, default=80,
help='Port number (default: 80)')
parser.add_argument('--protocol', type=str, default='http',
choices=['http', 'https'],
help='Protocol to use (default: http)')
args = parser.parse_args()
base_url = f"{args.protocol}://{args.host}"
if args.port != 80:
base_url += f":{args.port}"
tester = ApiTester(base_url)
success = tester.run_tests()
# Return non-zero exit code if any tests failed
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Test script for the ESP32 Modbus REST API /system/logs and /system/log-level endpoints.
"""
import requests
import json
import argparse
import sys
from colorama import init, Fore, Style
# Initialize colorama
init()
# Valid log levels
VALID_LOG_LEVELS = ["none", "error", "warning", "notice", "trace", "verbose"]
def print_success(message):
print(f"{Fore.GREEN}✓ SUCCESS: {message}{Style.RESET_ALL}")
def print_fail(message, error=None):
print(f"{Fore.RED}✗ FAIL: {message}{Style.RESET_ALL}")
if error:
print(f" {Fore.YELLOW}Error: {error}{Style.RESET_ALL}")
def print_info(message):
print(f"{Fore.BLUE} INFO: {message}{Style.RESET_ALL}")
def print_response(response):
try:
# Attempt to pretty-print JSON if possible
data = response.json()
if isinstance(data, list):
print(f"{Fore.CYAN}Response (JSON Array, {len(data)} items):{Style.RESET_ALL}")
# Print first few and last few lines if too long
limit = 15
if len(data) > 2 * limit:
for i in range(limit):
print(f" {data[i]}")
print(f" ... ({len(data) - 2 * limit} more lines) ...")
for i in range(len(data) - limit, len(data)):
print(f" {data[i]}")
else:
for line in data:
print(f" {line}")
else:
formatted_json = json.dumps(data, indent=2)
print(f"{Fore.CYAN}Response (JSON Object): {formatted_json}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.CYAN}Response (raw): {response.text[:500]}...{Style.RESET_ALL}") # Limit raw output
print(f"{Fore.YELLOW} Could not parse JSON: {e}{Style.RESET_ALL}")
def test_get_logs(base_url):
api_url = f"{base_url}/api/v1/system/logs"
fail_count = 0
# Test GET logs without level parameter
print_info(f"Testing GET {api_url}")
try:
response = requests.get(api_url, timeout=10)
print_response(response)
if response.status_code == 200:
try:
data = response.json()
if isinstance(data, list):
print_success(f"Logs endpoint returned a list with {len(data)} items.")
# Optional: Check if items are strings
if data and not isinstance(data[0], str):
print_fail("Log items do not appear to be strings.")
fail_count += 1
else:
print_fail("Response is not a JSON list as expected.")
fail_count += 1
except json.JSONDecodeError as e:
print_fail("Response is not valid JSON.", str(e))
fail_count += 1
except Exception as e:
print_fail("Error processing JSON response.", str(e))
fail_count += 1
else:
print_fail(f"Endpoint returned status code {response.status_code}")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to connect to the endpoint.", str(e))
fail_count += 1
except Exception as e:
print_fail("An unexpected error occurred.", str(e))
fail_count += 1
# Test GET logs with level parameter
print_info(f"Testing GET {api_url} with level parameter")
for level in VALID_LOG_LEVELS:
print_info(f"Testing GET {api_url}?level={level}")
try:
response = requests.get(f"{api_url}?level={level}", timeout=10)
print_response(response)
if response.status_code == 200:
try:
data = response.json()
if isinstance(data, list):
print_success(f"Logs endpoint with level={level} returned a list with {len(data)} items.")
# Optional: Check if items are strings
if data and not isinstance(data[0], str):
print_fail("Log items do not appear to be strings.")
fail_count += 1
else:
print_fail("Response is not a JSON list as expected.")
fail_count += 1
except json.JSONDecodeError as e:
print_fail("Response is not valid JSON.", str(e))
fail_count += 1
except Exception as e:
print_fail("Error processing JSON response.", str(e))
fail_count += 1
else:
print_fail(f"Endpoint returned status code {response.status_code}")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to connect to the endpoint.", str(e))
fail_count += 1
except Exception as e:
print_fail("An unexpected error occurred.", str(e))
fail_count += 1
# Test GET logs with invalid level parameter
print_info(f"Testing GET {api_url} with invalid level parameter")
try:
response = requests.get(f"{api_url}?level=invalid", timeout=10)
print_response(response)
if response.status_code == 400:
print_success("Server correctly rejected invalid log level")
else:
print_fail(f"Server did not reject invalid log level (got {response.status_code}, expected 400)")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to connect to the endpoint.", str(e))
fail_count += 1
except Exception as e:
print_fail("An unexpected error occurred.", str(e))
fail_count += 1
return fail_count
def test_log_level(base_url):
api_url = f"{base_url}/api/v1/system/log-level"
fail_count = 0
# Test GET log level
print_info(f"Testing GET {api_url}")
try:
response = requests.get(api_url, timeout=10)
print_response(response)
if response.status_code == 200:
try:
data = response.json()
if "level" in data and data["level"] in VALID_LOG_LEVELS:
print_success(f"Current log level is '{data['level']}'")
initial_level = data["level"]
else:
print_fail("Response missing 'level' field or invalid level value")
fail_count += 1
except json.JSONDecodeError as e:
print_fail("Response is not valid JSON.", str(e))
fail_count += 1
else:
print_fail(f"GET endpoint returned status code {response.status_code}")
fail_count += 1
# Test PUT log level - try each valid level
print_info(f"Testing GET {api_url} to set different levels")
for level in VALID_LOG_LEVELS:
print_info(f"Setting log level to '{level}'")
try:
response = requests.get(f"{api_url}?level={level}", timeout=10)
print_response(response)
if response.status_code == 200:
try:
data = response.json()
if "success" in data and data["success"] and "level" in data and data["level"] == level:
print_success(f"Successfully set log level to '{level}'")
else:
print_fail(f"Failed to set log level to '{level}'")
fail_count += 1
except json.JSONDecodeError as e:
print_fail("Response is not valid JSON.", str(e))
fail_count += 1
else:
print_fail(f"GET endpoint returned status code {response.status_code}")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail(f"Failed to set log level to '{level}'.", str(e))
fail_count += 1
# Test invalid log level
print_info("Testing GET with invalid log level")
try:
response = requests.get(f"{api_url}?level=invalid", timeout=10)
print_response(response)
if response.status_code == 400:
print_success("Server correctly rejected invalid log level")
else:
print_fail(f"Server did not reject invalid log level (got {response.status_code}, expected 400)")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to test invalid log level.", str(e))
fail_count += 1
# Restore initial log level
print_info(f"Restoring initial log level '{initial_level}'")
try:
response = requests.get(f"{api_url}?level={initial_level}", timeout=10)
if response.status_code == 200:
print_success(f"Restored log level to '{initial_level}'")
else:
print_fail(f"Failed to restore initial log level")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to restore initial log level.", str(e))
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to connect to the endpoint.", str(e))
fail_count += 1
except Exception as e:
print_fail("An unexpected error occurred.", str(e))
fail_count += 1
return fail_count
def main():
parser = argparse.ArgumentParser(description='Test the ESP32 Modbus REST API /system/logs and /system/log-level endpoints')
parser.add_argument('--host', type=str, default='modbus-esp32.local',
help='Hostname or IP address of the ESP32 device (default: modbus-esp32.local)')
parser.add_argument('--port', type=int, default=80,
help='Port number (default: 80)')
parser.add_argument('--protocol', type=str, default='http',
choices=['http', 'https'],
help='Protocol to use (default: http)')
args = parser.parse_args()
base_url = f"{args.protocol}://{args.host}"
if args.port != 80:
base_url += f":{args.port}"
fail_count = 0
# Test logs endpoint
print("-" * 80)
print_info("Testing system logs endpoint")
fail_count += test_get_logs(base_url)
# Test log level endpoint
print("-" * 80)
print_info("Testing log level endpoint")
fail_count += test_log_level(base_url)
print("-" * 80)
if fail_count == 0:
print(f"{Fore.GREEN}✓ All tests passed!{Style.RESET_ALL}")
else:
print(f"{Fore.RED}{fail_count} check(s) failed.{Style.RESET_ALL}")
sys.exit(fail_count)
if __name__ == "__main__":
main()
+334
View File
@@ -0,0 +1,334 @@
import argparse
import logging
import time
import sys
import os
import json
import random
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ConnectionException, ModbusIOException
from pymodbus.pdu import ExceptionResponse
# --- Configuration ---
DEFAULT_MODBUS_PORT = 502
MIN_DELAY_MS = 30
MAX_DELAY_MS = 500
DELAY_STEP_MS = 5 # How much to change delay each cycle
DEFAULT_POLL_DELAY_MS = MIN_DELAY_MS # Start at min delay
DEFAULT_COIL_ADDRESS = 0
DEFAULT_COIL_COUNT = 10
DEFAULT_REGISTER_ADDRESS = 0
DEFAULT_REGISTER_COUNT = 10
LOG_LEVEL = logging.INFO
OUTPUT_DIR = "tmp"
OUTPUT_FILE = os.path.join(OUTPUT_DIR, "long-poll.json")
MAX_ERRORS = 1000
WRITE_PROBABILITY = 0.1 # 10% chance to write each cycle (per type)
# --- Modbus Exception Code Mapping ---
# (Same mapping as in modbus_read_registers.py can be used if needed)
MODBUS_EXCEPTIONS = {
1: "Illegal Function",
2: "Illegal Data Address",
3: "Illegal Data Value",
4: "Slave Device Failure",
5: "Acknowledge",
6: "Slave Device Busy",
# ... add others if necessary
}
# --- Setup Logging ---
# Configure logging to output to stdout
logging.basicConfig(
level=LOG_LEVEL,
format='%(asctime)s - %(levelname)s - %(message)s',
stream=sys.stdout # Ensure logs go to console
)
# --- Argument Parsing ---
parser = argparse.ArgumentParser(description='Continuously poll Modbus TCP server for coils and registers.')
parser.add_argument('--ip-address', type=str, default='192.168.1.250',
help='IP address of the Modbus TCP server (e.g., ESP32). Defaults to 192.168.1.250')
parser.add_argument('--port', type=int, default=DEFAULT_MODBUS_PORT,
help=f'Port of the Modbus TCP server. Defaults to {DEFAULT_MODBUS_PORT}')
parser.add_argument('--delay', type=float, default=DEFAULT_POLL_DELAY_MS / 1000.0,
help=f'Initial polling delay in seconds. Will vary between {MIN_DELAY_MS/1000.0}s and {MAX_DELAY_MS/1000.0}s. Defaults to {DEFAULT_POLL_DELAY_MS / 1000.0}s')
parser.add_argument('--coil-addr', type=int, default=DEFAULT_COIL_ADDRESS,
help=f'Starting address for reading coils. Defaults to {DEFAULT_COIL_ADDRESS}')
parser.add_argument('--coil-count', type=int, default=DEFAULT_COIL_COUNT,
help=f'Number of coils to read. Defaults to {DEFAULT_COIL_COUNT}')
parser.add_argument('--reg-addr', type=int, default=DEFAULT_REGISTER_ADDRESS,
help=f'Starting address for reading holding registers. Defaults to {DEFAULT_REGISTER_ADDRESS}')
parser.add_argument('--reg-count', type=int, default=DEFAULT_REGISTER_COUNT,
help=f'Number of holding registers to read. Defaults to {DEFAULT_REGISTER_COUNT}')
args = parser.parse_args()
# Validate initial delay
if not (MIN_DELAY_MS / 1000.0 <= args.delay <= MAX_DELAY_MS / 1000.0):
logging.error(f"Error: Initial polling delay must be between {MIN_DELAY_MS/1000.0}s and {MAX_DELAY_MS/1000.0}s. Provided: {args.delay}s")
sys.exit(1)
# poll_delay_sec = args.delay # Replaced by dynamic delay
# --- Statistics Tracking ---
stats = {
"poll_cycles": 0,
"connection_success": 0,
"connection_failure": 0,
"coil_read_success": 0,
"coil_read_error": 0,
"register_read_success": 0,
"register_read_error": 0,
"coil_write_success": 0,
"coil_write_error": 0,
"register_write_success": 0,
"register_write_error": 0,
"last_coil_values": None,
"last_register_values": None
}
def write_stats():
"""Writes the current statistics to the JSON file."""
try:
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(OUTPUT_FILE, 'w') as f:
json.dump(stats, f, indent=4)
logging.debug(f"Statistics successfully written to {OUTPUT_FILE}")
except IOError as e:
logging.error(f"Failed to write statistics to {OUTPUT_FILE}: {e}")
except Exception as e:
logging.error(f"An unexpected error occurred during statistics write: {e}")
# --- Main Script ---
client = ModbusTcpClient(args.ip_address, port=args.port)
logging.info(f"Starting Modbus long poll test:")
logging.info(f" Target: {args.ip_address}:{args.port}")
logging.info(f" Polling Delay: Varies {MIN_DELAY_MS/1000.0:.3f}s - {MAX_DELAY_MS/1000.0:.3f}s (Initial: {args.delay:.3f}s)")
logging.info(f" Coils: Addr={args.coil_addr}, Count={args.coil_count}")
logging.info(f" Registers: Addr={args.reg_addr}, Count={args.reg_count}")
logging.info(f" Max Errors: {MAX_ERRORS}")
logging.info(f" Write Probability: {WRITE_PROBABILITY*100}%")
logging.info("Press Ctrl+C to stop.")
# --- Dynamic Delay State ---
current_delay_sec = args.delay
delay_direction = 1 # 1 for increasing, -1 for decreasing
min_delay_sec = MIN_DELAY_MS / 1000.0
max_delay_sec = MAX_DELAY_MS / 1000.0
delay_step_sec = DELAY_STEP_MS / 1000.0
try:
while True:
stats["poll_cycles"] += 1
logging.debug(f"--- Poll Cycle {stats['poll_cycles']} ---")
cycle_connection_error = False # Track connection error specifically for this cycle
try:
if not client.is_socket_open():
logging.info(f"Attempting to connect to {args.ip_address}:{args.port}...")
connection_success = client.connect()
if not connection_success:
logging.error("Connection failed. Retrying next cycle.")
stats["connection_failure"] += 1
cycle_connection_error = True
# No time.sleep here, handled at the end of the loop
# continue # Don't continue, let it write stats and sleep
else:
logging.info("Connection successful.")
stats["connection_success"] += 1
# Only attempt reads if connection is presumably okay for this cycle
if not cycle_connection_error and client.is_socket_open():
# --- Read Coils ---
logging.debug(f"Reading {args.coil_count} coils from address {args.coil_addr}...")
try:
coil_response = client.read_coils(address=args.coil_addr, count=args.coil_count)
if coil_response.isError():
stats["coil_read_error"] += 1
stats["last_coil_values"] = "Error"
if isinstance(coil_response, ExceptionResponse):
error_code = coil_response.exception_code
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error reading coils: Code {error_code} - {error_message}. Response: {coil_response}")
else:
logging.error(f"Failed to read coils. Response: {coil_response}")
elif isinstance(coil_response, ModbusIOException):
stats["coil_read_error"] += 1
stats["last_coil_values"] = "IOError"
logging.error(f"Modbus IO exception reading coils: {coil_response}")
client.close() # Close socket on IO error
else:
stats["coil_read_success"] += 1
coil_values = coil_response.bits[:args.coil_count] # Ensure correct count
stats["last_coil_values"] = coil_values
logging.info(f"Successfully read {len(coil_values)} coils: {coil_values}")
except ConnectionException as ce:
stats["coil_read_error"] += 1 # Count as coil error and connection failure
stats["connection_failure"] += 1
stats["last_coil_values"] = "ConnectionError"
logging.error(f"Connection error during coil read: {ce}. Closing connection.")
if client.is_socket_open(): client.close()
except Exception as e:
stats["coil_read_error"] += 1
stats["last_coil_values"] = f"Exception: {e}"
logging.error(f"Unexpected error during coil read: {e}")
# --- Read Holding Registers ---
if client.is_socket_open(): # Check connection again before next read
logging.debug(f"Reading {args.reg_count} holding registers from address {args.reg_addr}...")
try:
register_response = client.read_holding_registers(address=args.reg_addr, count=args.reg_count)
if register_response.isError():
stats["register_read_error"] += 1
stats["last_register_values"] = "Error"
if isinstance(register_response, ExceptionResponse):
error_code = register_response.exception_code
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error reading registers: Code {error_code} - {error_message}. Response: {register_response}")
else:
logging.error(f"Failed to read registers. Response: {register_response}")
elif isinstance(register_response, ModbusIOException):
stats["register_read_error"] += 1
stats["last_register_values"] = "IOError"
logging.error(f"Modbus IO exception reading registers: {register_response}")
client.close() # Close socket on IO error
else:
stats["register_read_success"] += 1
register_values = register_response.registers[:args.reg_count] # Ensure correct count
stats["last_register_values"] = register_values
logging.info(f"Successfully read {len(register_values)} registers: {register_values}")
except ConnectionException as ce:
stats["register_read_error"] += 1 # Count as register error and connection failure
stats["connection_failure"] += 1
stats["last_register_values"] = "ConnectionError"
logging.error(f"Connection error during register read: {ce}. Closing connection.")
if client.is_socket_open(): client.close()
except Exception as e:
stats["register_read_error"] += 1
stats["last_register_values"] = f"Exception: {e}"
logging.error(f"Unexpected error during register read: {e}")
# --- Random Writes ---
# Write Coil?
if random.random() < WRITE_PROBABILITY:
write_addr = random.randint(args.coil_addr, args.coil_addr + args.coil_count - 1)
write_val = random.choice([True, False])
logging.debug(f"Attempting to write Coil {write_addr} = {write_val}")
try:
write_response = client.write_coil(write_addr, write_val)
if write_response.isError():
stats["coil_write_error"] += 1
if isinstance(write_response, ExceptionResponse):
error_code = write_response.exception_code
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error writing coil {write_addr}: Code {error_code} - {error_message}. Response: {write_response}")
else:
logging.error(f"Failed to write coil {write_addr}. Response: {write_response}")
elif isinstance(write_response, ModbusIOException):
stats["coil_write_error"] += 1
logging.error(f"Modbus IO exception writing coil {write_addr}: {write_response}")
client.close()
else:
stats["coil_write_success"] += 1
logging.info(f"Successfully wrote Coil {write_addr} = {write_val}")
except ConnectionException as ce:
stats["coil_write_error"] += 1
stats["connection_failure"] += 1 # Also count as connection failure
logging.error(f"Connection error during coil write to {write_addr}: {ce}. Closing connection.")
if client.is_socket_open(): client.close()
except Exception as e:
stats["coil_write_error"] += 1
logging.error(f"Unexpected error during coil write to {write_addr}: {e}")
# Write Register?
if client.is_socket_open() and random.random() < WRITE_PROBABILITY:
write_addr = random.randint(args.reg_addr, args.reg_addr + args.reg_count - 1)
write_val = random.randint(0, 65535)
logging.debug(f"Attempting to write Register {write_addr} = {write_val}")
try:
write_response = client.write_register(write_addr, write_val)
if write_response.isError():
stats["register_write_error"] += 1
if isinstance(write_response, ExceptionResponse):
error_code = write_response.exception_code
error_message = MODBUS_EXCEPTIONS.get(error_code, f"Unknown error code {error_code}")
logging.error(f"Modbus error writing register {write_addr}: Code {error_code} - {error_message}. Response: {write_response}")
else:
logging.error(f"Failed to write register {write_addr}. Response: {write_response}")
elif isinstance(write_response, ModbusIOException):
stats["register_write_error"] += 1
logging.error(f"Modbus IO exception writing register {write_addr}: {write_response}")
client.close()
else:
stats["register_write_success"] += 1
logging.info(f"Successfully wrote Register {write_addr} = {write_val}")
except ConnectionException as ce:
stats["register_write_error"] += 1
stats["connection_failure"] += 1 # Also count as connection failure
logging.error(f"Connection error during register write to {write_addr}: {ce}. Closing connection.")
if client.is_socket_open(): client.close()
except Exception as e:
stats["register_write_error"] += 1
logging.error(f"Unexpected error during register write to {write_addr}: {e}")
except ConnectionException as ce:
# Handle connection errors during the cycle's connection attempt
stats["connection_failure"] += 1 # Ensure this is counted if connect() fails
logging.error(f"Connection error during connect attempt: {ce}")
if client.is_socket_open():
client.close() # Ensure closed if error occurred after partial connect
except Exception as e:
# Catch-all for unexpected errors in the main loop
logging.error(f"An unexpected error occurred in poll cycle {stats['poll_cycles']}: {e}")
if client.is_socket_open():
client.close() # Attempt to clean up connection
# --- Write stats for this cycle ---
write_stats()
# --- Check for error limit ---
total_errors = stats["connection_failure"] + stats["coil_read_error"] + stats["register_read_error"] + \
stats["coil_write_error"] + stats["register_write_error"]
if total_errors >= MAX_ERRORS:
logging.error(f"Stopping test: Reached maximum error limit of {MAX_ERRORS} (Total errors: {total_errors}).")
break # Exit the while loop
# --- Wait for next poll cycle --- Adjust Delay ---
sleep_time = current_delay_sec
logging.debug(f"Waiting {sleep_time:.3f}s before next poll...")
time.sleep(sleep_time)
# Update delay for next cycle
current_delay_sec += delay_step_sec * delay_direction
if current_delay_sec >= max_delay_sec:
current_delay_sec = max_delay_sec
delay_direction = -1
logging.debug("Reached max delay, reversing direction.")
elif current_delay_sec <= min_delay_sec:
current_delay_sec = min_delay_sec
delay_direction = 1
logging.debug("Reached min delay, reversing direction.")
except KeyboardInterrupt:
logging.info("\nCtrl+C received. Stopping the poll test.")
except Exception as e:
logging.error(f"An critical error occurred: {e}")
finally:
logging.info("Performing final actions...")
if client.is_socket_open():
client.close()
logging.info("Modbus connection closed.")
# --- Write final statistics ---
logging.info("Writing final statistics...")
write_stats() # Ensure stats are written on exit
logging.info(f"Test finished after {stats['poll_cycles']} poll cycles.")
logging.info(f"Final Stats: {stats}") # Log final stats to console too
sys.exit(0)
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Test script for the ESP32 Modbus REST API /system/info endpoint.
"""
import requests
import json
import argparse
import sys
from colorama import init, Fore, Style
# Initialize colorama
init()
def print_success(message):
print(f"{Fore.GREEN}✓ SUCCESS: {message}{Style.RESET_ALL}")
def print_fail(message, error=None):
print(f"{Fore.RED}✗ FAIL: {message}{Style.RESET_ALL}")
if error:
print(f" {Fore.YELLOW}Error: {error}{Style.RESET_ALL}")
def print_info(message):
print(f"{Fore.BLUE} INFO: {message}{Style.RESET_ALL}")
def print_response(response):
try:
formatted_json = json.dumps(response.json(), indent=2)
print(f"{Fore.CYAN}Response: {formatted_json}{Style.RESET_ALL}")
except Exception as e:
print(f"{Fore.CYAN}Response (raw): {response.text}{Style.RESET_ALL}")
print(f"{Fore.YELLOW} Could not parse JSON: {e}{Style.RESET_ALL}")
def main():
parser = argparse.ArgumentParser(description='Test the ESP32 Modbus REST API /system/info endpoint')
parser.add_argument('--host', type=str, default='modbus-esp32.local',
help='Hostname or IP address of the ESP32 device (default: modbus-esp32.local)')
parser.add_argument('--port', type=int, default=80,
help='Port number (default: 80)')
parser.add_argument('--protocol', type=str, default='http',
choices=['http', 'https'],
help='Protocol to use (default: http)')
args = parser.parse_args()
base_url = f"{args.protocol}://{args.host}"
if args.port != 80:
base_url += f":{args.port}"
api_url = f"{base_url}/api/v1/system/info"
fail_count = 0
print_info(f"Testing GET {api_url}")
try:
response = requests.get(api_url, timeout=10) # Increased timeout slightly
print_response(response)
if response.status_code == 200:
try:
data = response.json()
# Basic checks
if not all(k in data for k in ["version", "board", "uptime", "timestamp"]):
print_fail("System info missing required base fields.")
fail_count += 1
# Check components array
if "components" in data:
if isinstance(data["components"], list):
print_success("System info includes 'components' array.")
if not data["components"]:
print_info("Component array is present but empty (no Modbus components registered?).")
else:
# Check structure of the first component if array is not empty
first_comp = data["components"][0]
if not all(k in first_comp for k in ["id", "name", "startAddress", "count"]):
print_fail("First component in array has incorrect structure.")
fail_count += 1
else:
print_success("Component structure looks correct.")
else:
print_fail("'components' field is not a list.")
fail_count += 1
else:
# This might be acceptable if ModbusManager isn't active
print_info("System info does not include 'components' array (ModbusManager inactive or no components?).")
# If all checks passed so far
if fail_count == 0:
print_success("System info response structure is valid.")
except json.JSONDecodeError as e:
print_fail("Response is not valid JSON.", str(e))
fail_count += 1
else:
print_fail(f"Endpoint returned status code {response.status_code}")
fail_count += 1
except requests.exceptions.RequestException as e:
print_fail("Failed to connect to the endpoint.", str(e))
fail_count += 1
except Exception as e:
print_fail("An unexpected error occurred.", str(e))
fail_count += 1
print("-" * 80)
if fail_count == 0:
print(f"{Fore.GREEN}✓ All checks passed!{Style.RESET_ALL}")
else:
print(f"{Fore.RED}{fail_count} check(s) failed.{Style.RESET_ALL}")
sys.exit(fail_count)
if __name__ == "__main__":
main()
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env python3
"""
Test script for the ESP32 Modbus SimpleWebServer API
This script tests the actual working endpoints.
"""
import requests
import json
import argparse
import time
import sys
from colorama import init, Fore, Style
# Initialize colorama for colored terminal output
init()
class ApiTester:
def __init__(self, base_url):
self.base_url = base_url
self.api_url = f"{base_url}/api/v1" # Add v1 prefix
self.success_count = 0
self.fail_count = 0
def print_success(self, message):
print(f"{Fore.GREEN}✓ SUCCESS: {message}{Style.RESET_ALL}")
self.success_count += 1
def print_fail(self, message, error=None):
print(f"{Fore.RED}✗ FAIL: {message}{Style.RESET_ALL}")
if error:
print(f" {Fore.YELLOW}Error: {error}{Style.RESET_ALL}")
self.fail_count += 1
def print_info(self, message):
print(f"{Fore.BLUE} INFO: {message}{Style.RESET_ALL}")
def print_response(self, response):
try:
formatted_json = json.dumps(response.json(), indent=2)
print(f"{Fore.CYAN}Response: {formatted_json}{Style.RESET_ALL}")
except:
print(f"{Fore.CYAN}Response: {response.text}{Style.RESET_ALL}")
def run_tests(self):
"""Run all API tests"""
self.print_info(f"Testing API at {self.api_url}")
print("=" * 80)
# Test coil endpoints - update to V1
self.test_coils_list() # GET /api/v1/coils
print("-" * 80)
self.test_coil_get(21) # GET /api/v1/coils?address=21
print("-" * 80)
self.test_coil_set(51) # Test toggle coil via POST /api/v1/coils/51?value=...
print("-" * 80)
# Test register endpoints - update to V1
self.test_registers_list() # GET /api/v1/registers
print("-" * 80)
self.test_register_get(20) # GET /api/v1/registers?address=20
print("-" * 80)
self.test_register_set(20, 42) # POST /api/v1/registers/20?value=42
print("=" * 80)
# Print summary
print(f"\nTest Summary: {self.success_count} passed, {self.fail_count} failed")
return self.fail_count == 0
def test_coils_list(self):
"""Test the coils list endpoint"""
self.print_info("Testing GET /v1/coils")
try:
# Updated URL, remove unused query params
response = requests.get(f"{self.api_url}/coils", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'coils' in data and isinstance(data['coils'], list):
self.print_success("Coils endpoint returned valid data")
else:
self.print_fail("Coils endpoint returned invalid data")
else:
self.print_fail(f"Coils endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to coils endpoint", str(e))
def test_coil_get(self, address):
"""Test getting a specific coil"""
self.print_info(f"Testing GET /v1/coils?address={address}")
try:
# Updated URL and use query parameter
response = requests.get(f"{self.api_url}/coils", params={'address': address}, timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'address' in data and 'value' in data:
self.print_success(f"Coil {address} endpoint returned valid data")
else:
self.print_fail(f"Coil {address} endpoint returned incomplete data")
else:
self.print_fail(f"Coil {address} endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail(f"Failed to connect to coil {address} endpoint", str(e))
# Renamed from test_coil_toggle to test_coil_set as per swagger
def test_coil_set(self, address):
"""Test setting a coil"""
self.print_info(f"Testing POST /v1/coils/{address}")
# First, get the current value
try:
# Updated URL and use query parameter
get_response = requests.get(f"{self.api_url}/coils", params={'address': address}, timeout=5)
if get_response.status_code == 200:
current_value = get_response.json().get('value', False)
new_value = not current_value
# Now set the value
try:
self.print_info(f"Setting coil {address} to {new_value}")
# Updated URL structure and use query param for value
post_response = requests.post(
f"{self.api_url}/coils/{address}",
params={'value': new_value},
timeout=5
)
if post_response.status_code == 200:
data = post_response.json()
self.print_response(post_response)
# Check response format according to swagger
if ('success' in data and data['success'] and
'address' in data and data['address'] == address and
'value' in data and data['value'] == new_value):
self.print_success(f"Successfully set coil {address}")
# Restore original state to be nice
time.sleep(1)
requests.post(
f"{self.api_url}/coils/{address}",
params={'value': current_value},
timeout=5
)
self.print_info(f"Reset coil {address} to original state")
else:
self.print_fail(f"Failed to set coil {address}, unexpected response format")
else:
self.print_fail(f"Coil set endpoint returned status code {post_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to set coil {address}", str(e))
else:
self.print_fail(f"Failed to get current coil state: {get_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to get current coil state", str(e))
def test_registers_list(self):
"""Test the registers list endpoint"""
self.print_info("Testing GET /v1/registers")
try:
# Updated URL, remove unused query params
response = requests.get(f"{self.api_url}/registers", timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'registers' in data and isinstance(data['registers'], list):
self.print_success("Registers endpoint returned valid data")
else:
self.print_fail("Registers endpoint returned invalid data")
else:
self.print_fail(f"Registers endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail("Failed to connect to registers endpoint", str(e))
def test_register_get(self, address):
"""Test getting a specific register"""
self.print_info(f"Testing GET /v1/registers?address={address}")
try:
# Updated URL and use query parameter
response = requests.get(f"{self.api_url}/registers", params={'address': address}, timeout=5)
if response.status_code == 200:
data = response.json()
self.print_response(response)
if 'address' in data and 'value' in data:
self.print_success(f"Register {address} endpoint returned valid data")
else:
self.print_fail(f"Register {address} endpoint returned incomplete data")
else:
self.print_fail(f"Register {address} endpoint returned status code {response.status_code}")
except Exception as e:
self.print_fail(f"Failed to connect to register {address} endpoint", str(e))
# Renamed from test_register_update to test_register_set
def test_register_set(self, address, new_value):
"""Test setting a register"""
self.print_info(f"Testing POST /v1/registers/{address}")
# First, get the current value
try:
# Updated URL and use query parameter
get_response = requests.get(f"{self.api_url}/registers", params={'address': address}, timeout=5)
if get_response.status_code == 200:
current_value = get_response.json().get('value', 0)
# Now set the value
try:
self.print_info(f"Setting register {address} to {new_value}")
# Updated URL structure and use query param for value
post_response = requests.post(
f"{self.api_url}/registers/{address}",
params={'value': new_value},
timeout=5
)
if post_response.status_code == 200:
data = post_response.json()
self.print_response(post_response)
# Check response format according to swagger
if ('success' in data and data['success'] and
'address' in data and data['address'] == address and
'value' in data and data['value'] == new_value):
self.print_success(f"Successfully set register {address}")
# Restore original value to be nice
time.sleep(1)
requests.post(
f"{self.api_url}/registers/{address}",
params={'value': current_value},
timeout=5
)
self.print_info(f"Reset register {address} to original value")
else:
self.print_fail(f"Failed to set register {address}, unexpected response format")
else:
self.print_fail(f"Register set endpoint returned status code {post_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to set register {address}", str(e))
else:
self.print_fail(f"Failed to get current register value: {get_response.status_code}")
except Exception as e:
self.print_fail(f"Failed to get current register value", str(e))
def main():
parser = argparse.ArgumentParser(description='Test the ESP32 Modbus SimpleWebServer API')
parser.add_argument('--host', type=str, default='192.168.1.250',
help='Hostname or IP address of the ESP32 device (default: 192.168.1.250)')
parser.add_argument('--port', type=int, default=80,
help='Port number (default: 80)')
parser.add_argument('--protocol', type=str, default='http',
choices=['http', 'https'],
help='Protocol to use (default: http)')
args = parser.parse_args()
base_url = f"{args.protocol}://{args.host}"
if args.port != 80:
base_url += f":{args.port}"
tester = ApiTester(base_url)
success = tester.run_tests()
# Return non-zero exit code if any tests failed
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
Extract all common functionallity as Typescript functions
+6
View File
@@ -0,0 +1,6 @@
kbot-d --model=google/gemini-2.5-flash-preview:thinking \
--prompt=./scripts/ts-commons.md \
--include=./src/modbus/ModbusTypes.cpp \
--mode=completion --preferences=none \
--dst=../web/client/src/modbus-commons.ts \
--filters=code
+8
View File
@@ -0,0 +1,8 @@
Extract all structs & enums as Typescript file
Structs : in capital, prefix by context, eg: Modbus = MB_
Enums : in capital, prefix with E_
Types: in capital, prefix with T_
- Group by application (RTU / TCP)
- add comments
+6
View File
@@ -0,0 +1,6 @@
kbot-d --model=google/gemini-2.5-flash-preview:thinking \
--prompt=./scripts/ts.md \
--include=./src/modbus/*.h \
--mode=completion --preferences=none \
--dst=../web/client/src/modbus-types.ts \
--filters=code
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
cp ./config/* ./data/