polymech - fw latest | web ui
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 ---")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
Extract all common functionallity as Typescript functions
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
cp ./config/* ./data/
|
||||
Reference in New Issue
Block a user