From 44db2f32f6112d8346473712b7a4aa3aee03bd14 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Thu, 19 Jun 2025 23:03:45 -0400 Subject: [PATCH 01/32] * improve eg4 handling * add json output to ease testing * add influxdb v1 support for ease of use with grafana --- classes/protocol_settings.py | 99 ++++++--- classes/transports/influxdb_out.py | 163 ++++++++++++++ classes/transports/json_out.py | 109 +++++++++ classes/transports/modbus_base.py | 66 +++++- config.influxdb.example | 22 ++ config.json_out.example | 43 ++++ documentation/README.md | 1 + .../influxdb_example.md | 177 +++++++++++++++ .../json_out_example.md | 144 ++++++++++++ documentation/usage/transports.md | 206 ++++++++++++++++++ protocols/eg4/eg4_v58.input_registry_map.csv | 2 +- pytests/test_influxdb_out.py | 116 ++++++++++ requirements.txt | 1 + 13 files changed, 1111 insertions(+), 38 deletions(-) create mode 100644 classes/transports/influxdb_out.py create mode 100644 classes/transports/json_out.py create mode 100644 config.influxdb.example create mode 100644 config.json_out.example create mode 100644 documentation/usage/configuration_examples/influxdb_example.md create mode 100644 documentation/usage/configuration_examples/json_out_example.md create mode 100644 pytests/test_influxdb_out.py diff --git a/classes/protocol_settings.py b/classes/protocol_settings.py index fb2cb35..8497082 100644 --- a/classes/protocol_settings.py +++ b/classes/protocol_settings.py @@ -626,7 +626,7 @@ def process_row(row): concatenate_registers.append(i) if concatenate_registers: - r = range(len(concatenate_registers)) + r = range(1) # Only create one entry for concatenated variables else: r = range(1) @@ -1111,41 +1111,82 @@ def process_registery(self, registry : Union[dict[int, int], dict[int, bytes]] , concatenate_registry : dict = {} info = {} + + # First pass: process all non-concatenated entries for entry in map: - if entry.register not in registry: continue - value = "" - - if isinstance(registry[entry.register], bytes): - value = self.process_register_bytes(registry, entry) - else: - value = self.process_register_ushort(registry, entry) - - #if item.unit: - # value = str(value) + item.unit + + if not entry.concatenate: + value = "" + if isinstance(registry[entry.register], bytes): + value = self.process_register_bytes(registry, entry) + else: + value = self.process_register_ushort(registry, entry) + info[entry.variable_name] = value + + # Second pass: process concatenated entries + for entry in map: + if entry.register not in registry: + continue + if entry.concatenate: - concatenate_registry[entry.register] = value - - all_exist = True - for key in entry.concatenate_registers: - if key not in concatenate_registry: - all_exist = False - break - if all_exist: - #if all(key in concatenate_registry for key in item.concatenate_registers): - concatenated_value = "" - for key in entry.concatenate_registers: - concatenated_value = concatenated_value + str(concatenate_registry[key]) - del concatenate_registry[key] - - #replace null characters with spaces and trim + # For concatenated entries, we need to process each register in the concatenate_registers list + concatenated_value = "" + all_registers_exist = True + + # For ASCII concatenated variables, extract 8-bit characters from 16-bit registers + if entry.data_type == Data_Type.ASCII: + for reg in entry.concatenate_registers: + if reg not in registry: + all_registers_exist = False + break + + reg_value = registry[reg] + # Extract high byte (bits 8-15) and low byte (bits 0-7) + high_byte = (reg_value >> 8) & 0xFF + low_byte = reg_value & 0xFF + + # Convert each byte to ASCII character (low byte first, then high byte) + low_char = chr(low_byte) + high_char = chr(high_byte) + concatenated_value += low_char + high_char + else: + for reg in entry.concatenate_registers: + if reg not in registry: + all_registers_exist = False + break + + # Create a temporary entry for this register to process it + temp_entry = registry_map_entry( + registry_type=entry.registry_type, + register=reg, + register_bit=0, + register_byte=0, + variable_name=f"temp_{reg}", + documented_name=f"temp_{reg}", + unit="", + unit_mod=1.0, + concatenate=False, + concatenate_registers=[], + values=[], + data_type=entry.data_type, + data_type_size=entry.data_type_size + ) + + if isinstance(registry[reg], bytes): + value = self.process_register_bytes(registry, temp_entry) + else: + value = self.process_register_ushort(registry, temp_entry) + + concatenated_value += str(value) + + if all_registers_exist: + # Replace null characters with spaces and trim for ASCII if entry.data_type == Data_Type.ASCII: concatenated_value = concatenated_value.replace("\x00", " ").strip() - + info[entry.variable_name] = concatenated_value - else: - info[entry.variable_name] = value return info diff --git a/classes/transports/influxdb_out.py b/classes/transports/influxdb_out.py new file mode 100644 index 0000000..24a3028 --- /dev/null +++ b/classes/transports/influxdb_out.py @@ -0,0 +1,163 @@ +import sys +from configparser import SectionProxy +from typing import TextIO +import time + +from defs.common import strtobool + +from ..protocol_settings import Registry_Type, WriteMode, registry_map_entry +from .transport_base import transport_base + + +class influxdb_out(transport_base): + ''' InfluxDB v1 output transport that writes data to an InfluxDB server ''' + host: str = "localhost" + port: int = 8086 + database: str = "solar" + username: str = "" + password: str = "" + measurement: str = "device_data" + include_timestamp: bool = True + include_device_info: bool = True + batch_size: int = 100 + batch_timeout: float = 10.0 + + client = None + batch_points = [] + last_batch_time = 0 + + def __init__(self, settings: SectionProxy): + self.host = settings.get("host", fallback=self.host) + self.port = settings.getint("port", fallback=self.port) + self.database = settings.get("database", fallback=self.database) + self.username = settings.get("username", fallback=self.username) + self.password = settings.get("password", fallback=self.password) + self.measurement = settings.get("measurement", fallback=self.measurement) + self.include_timestamp = strtobool(settings.get("include_timestamp", fallback=self.include_timestamp)) + self.include_device_info = strtobool(settings.get("include_device_info", fallback=self.include_device_info)) + self.batch_size = settings.getint("batch_size", fallback=self.batch_size) + self.batch_timeout = settings.getfloat("batch_timeout", fallback=self.batch_timeout) + + self.write_enabled = True # InfluxDB output is always write-enabled + super().__init__(settings) + + def connect(self): + """Initialize the InfluxDB client connection""" + self._log.info("influxdb_out connect") + + try: + from influxdb import InfluxDBClient + + # Create InfluxDB client + self.client = InfluxDBClient( + host=self.host, + port=self.port, + username=self.username if self.username else None, + password=self.password if self.password else None, + database=self.database + ) + + # Test connection + self.client.ping() + + # Create database if it doesn't exist + databases = self.client.get_list_database() + if not any(db['name'] == self.database for db in databases): + self._log.info(f"Creating database: {self.database}") + self.client.create_database(self.database) + + self.connected = True + self._log.info(f"Connected to InfluxDB at {self.host}:{self.port}") + + except ImportError: + self._log.error("InfluxDB client not installed. Please install with: pip install influxdb") + self.connected = False + except Exception as e: + self._log.error(f"Failed to connect to InfluxDB: {e}") + self.connected = False + + def write_data(self, data: dict[str, str], from_transport: transport_base): + """Write data to InfluxDB""" + if not self.write_enabled or not self.connected: + return + + self._log.info(f"write data from [{from_transport.transport_name}] to influxdb_out transport") + self._log.info(data) + + # Prepare tags for InfluxDB + tags = {} + + # Add device information as tags if enabled + if self.include_device_info: + tags.update({ + "device_identifier": from_transport.device_identifier, + "device_name": from_transport.device_name, + "device_manufacturer": from_transport.device_manufacturer, + "device_model": from_transport.device_model, + "device_serial_number": from_transport.device_serial_number, + "transport": from_transport.transport_name + }) + + # Prepare fields (the actual data values) + fields = {} + for key, value in data.items(): + # Try to convert to numeric values for InfluxDB + try: + # Try to convert to float first + float_val = float(value) + # If it's an integer, store as int + if float_val.is_integer(): + fields[key] = int(float_val) + else: + fields[key] = float_val + except (ValueError, TypeError): + # If conversion fails, store as string + fields[key] = str(value) + + # Create InfluxDB point + point = { + "measurement": self.measurement, + "tags": tags, + "fields": fields + } + + # Add timestamp if enabled + if self.include_timestamp: + point["time"] = int(time.time() * 1e9) # Convert to nanoseconds + + # Add to batch + self.batch_points.append(point) + + # Check if we should flush the batch + current_time = time.time() + if (len(self.batch_points) >= self.batch_size or + (current_time - self.last_batch_time) >= self.batch_timeout): + self._flush_batch() + + def _flush_batch(self): + """Flush the batch of points to InfluxDB""" + if not self.batch_points: + return + + try: + self.client.write_points(self.batch_points) + self._log.info(f"Wrote {len(self.batch_points)} points to InfluxDB") + self.batch_points = [] + self.last_batch_time = time.time() + except Exception as e: + self._log.error(f"Failed to write batch to InfluxDB: {e}") + self.connected = False + + def init_bridge(self, from_transport: transport_base): + """Initialize bridge - not needed for InfluxDB output""" + pass + + def __del__(self): + """Cleanup on destruction - flush any remaining points""" + if self.batch_points: + self._flush_batch() + if self.client: + try: + self.client.close() + except Exception: + pass \ No newline at end of file diff --git a/classes/transports/json_out.py b/classes/transports/json_out.py new file mode 100644 index 0000000..51fe6d4 --- /dev/null +++ b/classes/transports/json_out.py @@ -0,0 +1,109 @@ +import json +import sys +from configparser import SectionProxy +from typing import TextIO + +from defs.common import strtobool + +from ..protocol_settings import Registry_Type, WriteMode, registry_map_entry +from .transport_base import transport_base + + +class json_out(transport_base): + ''' JSON output transport that writes data to a file or stdout ''' + output_file: str = "stdout" + pretty_print: bool = True + append_mode: bool = False + include_timestamp: bool = True + include_device_info: bool = True + + file_handle: TextIO = None + + def __init__(self, settings: SectionProxy): + self.output_file = settings.get("output_file", fallback=self.output_file) + self.pretty_print = strtobool(settings.get("pretty_print", fallback=self.pretty_print)) + self.append_mode = strtobool(settings.get("append_mode", fallback=self.append_mode)) + self.include_timestamp = strtobool(settings.get("include_timestamp", fallback=self.include_timestamp)) + self.include_device_info = strtobool(settings.get("include_device_info", fallback=self.include_device_info)) + + self.write_enabled = True # JSON output is always write-enabled + super().__init__(settings) + + def connect(self): + """Initialize the output file handle""" + self._log.info("json_out connect") + + if self.output_file.lower() == "stdout": + self.file_handle = sys.stdout + else: + try: + mode = "a" if self.append_mode else "w" + self.file_handle = open(self.output_file, mode, encoding='utf-8') + self.connected = True + except Exception as e: + self._log.error(f"Failed to open output file {self.output_file}: {e}") + self.connected = False + return + + self.connected = True + + def write_data(self, data: dict[str, str], from_transport: transport_base): + """Write data as JSON to the output file""" + if not self.write_enabled or not self.connected: + return + + self._log.info(f"write data from [{from_transport.transport_name}] to json_out transport") + self._log.info(data) + + # Prepare the JSON output structure + output_data = {} + + # Add device information if enabled + if self.include_device_info: + output_data["device"] = { + "identifier": from_transport.device_identifier, + "name": from_transport.device_name, + "manufacturer": from_transport.device_manufacturer, + "model": from_transport.device_model, + "serial_number": from_transport.device_serial_number, + "transport": from_transport.transport_name + } + + # Add timestamp if enabled + if self.include_timestamp: + import time + output_data["timestamp"] = time.time() + + # Add the actual data + output_data["data"] = data + + # Convert to JSON + if self.pretty_print: + json_string = json.dumps(output_data, indent=2, ensure_ascii=False) + else: + json_string = json.dumps(output_data, ensure_ascii=False) + + # Write to file + try: + if self.output_file.lower() != "stdout": + # For files, add a newline and flush + self.file_handle.write(json_string + "\n") + self.file_handle.flush() + else: + # For stdout, just print + print(json_string) + except Exception as e: + self._log.error(f"Failed to write to output: {e}") + self.connected = False + + def init_bridge(self, from_transport: transport_base): + """Initialize bridge - not needed for JSON output""" + pass + + def __del__(self): + """Cleanup file handle on destruction""" + if self.file_handle and self.output_file.lower() != "stdout": + try: + self.file_handle.close() + except: + pass \ No newline at end of file diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index d040746..20c1ae1 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -95,9 +95,18 @@ def connect(self): self.init_after_connect() def read_serial_number(self) -> str: + # First try to read "Serial Number" from input registers (for protocols like EG4 v58) + self._log.info("Looking for serial_number variable in input registers...") + serial_number = str(self.read_variable("Serial Number", Registry_Type.INPUT)) + self._log.info("read SN from input registers: " + serial_number) + if serial_number and serial_number != "None": + return serial_number + + # Then try holding registers (for other protocols) + self._log.info("Looking for serial_number variable in holding registers...") serial_number = str(self.read_variable("Serial Number", Registry_Type.HOLDING)) - self._log.info("read SN: " +serial_number) - if serial_number: + self._log.info("read SN from holding registers: " + serial_number) + if serial_number and serial_number != "None": return serial_number sn2 = "" @@ -267,8 +276,8 @@ def analyze_protocol(self, settings_dir : str = "protocols"): else: #perform registry scan ##batch_size = 1, read registers one by one; if out of bound. it just returns error - input_registry = self.read_modbus_registers(start=0, end=max_input_register, batch_size=45, registry_type=Registry_Type.INPUT) - holding_registry = self.read_modbus_registers(start=0, end=max_holding_register, batch_size=45, registry_type=Registry_Type.HOLDING) + input_registry = self.read_modbus_registers(start=0, end=max_input_register, registry_type=Registry_Type.INPUT) + holding_registry = self.read_modbus_registers(start=0, end=max_holding_register, registry_type=Registry_Type.HOLDING) if self.analyze_protocol_save_load: #save results if enabled with open(input_save_path, "w") as file: @@ -497,16 +506,57 @@ def read_variable(self, variable_name : str, registry_type : Registry_Type, entr start = entry.register end = entry.register else: - start = entry.register + start = min(entry.concatenate_registers) end = max(entry.concatenate_registers) registers = self.read_modbus_registers(start=start, end=end, registry_type=registry_type) - results = self.protocolSettings.process_registery(registers, registry_map) - return results[entry.variable_name] + + # Special handling for concatenated ASCII variables (like serial numbers) + if entry.concatenate and entry.data_type == Data_Type.ASCII: + concatenated_value = "" + + # For serial numbers, we need to extract 8-bit ASCII characters from 16-bit registers + # Each register contains two ASCII characters (low byte and high byte) + for reg in entry.concatenate_registers: + if reg in registers: + reg_value = registers[reg] + # Extract low byte (bits 0-7) and high byte (bits 8-15) + low_byte = reg_value & 0xFF + high_byte = (reg_value >> 8) & 0xFF + + # Convert each byte to ASCII character + low_char = chr(low_byte) + high_char = chr(high_byte) + + concatenated_value += low_char + high_char + else: + self._log.warning(f"Register {reg} not found in registry") + + result = concatenated_value.replace("\x00", " ").strip() + return result + + # Only process the specific entry, not the entire registry map + results = self.protocolSettings.process_registery(registers, [entry]) + result = results.get(entry.variable_name) + return result + else: + self._log.warning(f"Entry not found for variable: {variable_name}") + return None - def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, end : int = None, batch_size : int = 45, registry_type : Registry_Type = Registry_Type.INPUT ) -> dict: + def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, end : int = None, batch_size : int = None, registry_type : Registry_Type = Registry_Type.INPUT ) -> dict: ''' maybe move this to transport_base ?''' + # Get batch_size from protocol settings if not provided + if batch_size is None: + if hasattr(self, 'protocolSettings') and self.protocolSettings: + batch_size = self.protocolSettings.settings.get("batch_size", 45) + try: + batch_size = int(batch_size) + except (ValueError, TypeError): + batch_size = 45 + else: + batch_size = 45 + if not ranges: #ranges is empty, use min max if start == 0 and end is None: return {} #empty diff --git a/config.influxdb.example b/config.influxdb.example new file mode 100644 index 0000000..4d71085 --- /dev/null +++ b/config.influxdb.example @@ -0,0 +1,22 @@ +[influxdb_output] +type = influxdb_out +host = localhost +port = 8086 +database = solar +username = +password = +measurement = device_data +include_timestamp = true +include_device_info = true +batch_size = 100 +batch_timeout = 10.0 +log_level = INFO + +# Example bridge configuration +[modbus_rtu_source] +type = modbus_rtu +port = /dev/ttyUSB0 +baudrate = 9600 +protocol_version = growatt_2020_v1.24 +device_serial_number = 123456789 +bridge = influxdb_output \ No newline at end of file diff --git a/config.json_out.example b/config.json_out.example new file mode 100644 index 0000000..9772b9a --- /dev/null +++ b/config.json_out.example @@ -0,0 +1,43 @@ +[general] +log_level = INFO + +[transport.modbus_input] +# Modbus input transport - reads data from device +protocol_version = v0.14 +address = 1 +port = /dev/ttyUSB0 +baudrate = 9600 +bridge = transport.json_output +read_interval = 10 + +manufacturer = TestDevice +model = Test Model +serial_number = TEST123 + +[transport.json_output] +# JSON output transport - writes data to stdout +transport = json_out +output_file = stdout +pretty_print = true +include_timestamp = true +include_device_info = true + +# Alternative configurations (uncomment to use): + +# [transport.json_file] +# # JSON output to file +# transport = json_out +# output_file = /var/log/inverter_data.json +# pretty_print = false +# append_mode = true +# include_timestamp = true +# include_device_info = false + +# [transport.json_compact] +# # Compact JSON output +# transport = json_out +# output_file = /tmp/compact_data.json +# pretty_print = false +# append_mode = false +# include_timestamp = true +# include_device_info = false \ No newline at end of file diff --git a/documentation/README.md b/documentation/README.md index f756603..53ce9b3 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -32,6 +32,7 @@ This README file contains an index of all files in the documentation directory. - [modbus_rtu_to_modbus_tcp.md](usage/configuration_examples/modbus_rtu_to_modbus_tcp.md) - ModBus RTU to ModBus TCP - [modbus_rtu_to_mqtt.md](usage/configuration_examples/modbus_rtu_to_mqtt.md) - ModBus RTU to MQTT +- [influxdb_example.md](usage/configuration_examples/influxdb_example.md) - ModBus RTU to InfluxDB **3rdparty** diff --git a/documentation/usage/configuration_examples/influxdb_example.md b/documentation/usage/configuration_examples/influxdb_example.md new file mode 100644 index 0000000..56a500a --- /dev/null +++ b/documentation/usage/configuration_examples/influxdb_example.md @@ -0,0 +1,177 @@ +# InfluxDB Output Transport + +The InfluxDB output transport allows you to send data from your devices directly to an InfluxDB v1 server for time-series data storage and visualization. + +## Features + +- **Batch Writing**: Efficiently batches data points to reduce network overhead +- **Automatic Database Creation**: Creates the database if it doesn't exist +- **Device Information Tags**: Includes device metadata as InfluxDB tags for easy querying +- **Flexible Data Types**: Automatically converts data to appropriate InfluxDB field types +- **Configurable Timeouts**: Adjustable batch size and timeout settings + +## Configuration + +### Basic Configuration + +```ini +[influxdb_output] +type = influxdb_out +host = localhost +port = 8086 +database = solar +measurement = device_data +``` + +### Advanced Configuration + +```ini +[influxdb_output] +type = influxdb_out +host = localhost +port = 8086 +database = solar +username = admin +password = your_password +measurement = device_data +include_timestamp = true +include_device_info = true +batch_size = 100 +batch_timeout = 10.0 +log_level = INFO +``` + +### Configuration Options + +| Option | Default | Description | +|--------|---------|-------------| +| `host` | `localhost` | InfluxDB server hostname or IP address | +| `port` | `8086` | InfluxDB server port | +| `database` | `solar` | Database name (will be created if it doesn't exist) | +| `username` | `` | Username for authentication (optional) | +| `password` | `` | Password for authentication (optional) | +| `measurement` | `device_data` | InfluxDB measurement name | +| `include_timestamp` | `true` | Include timestamp in data points | +| `include_device_info` | `true` | Include device information as tags | +| `batch_size` | `100` | Number of points to batch before writing | +| `batch_timeout` | `10.0` | Maximum time (seconds) to wait before flushing batch | + +## Data Structure + +The InfluxDB output creates data points with the following structure: + +### Tags (if `include_device_info = true`) +- `device_identifier`: Device serial number (lowercase) +- `device_name`: Device name +- `device_manufacturer`: Device manufacturer +- `device_model`: Device model +- `device_serial_number`: Device serial number +- `transport`: Source transport name + +### Fields +All device data values are stored as fields. The transport automatically converts: +- Numeric strings to integers or floats +- Non-numeric strings remain as strings + +### Time +- Uses current timestamp in nanoseconds (if `include_timestamp = true`) +- Can be disabled for custom timestamp handling + +## Example Bridge Configuration + +```ini +# Source device (e.g., Modbus RTU) +[growatt_inverter] +type = modbus_rtu +port = /dev/ttyUSB0 +baudrate = 9600 +protocol_version = growatt_2020_v1.24 +device_serial_number = 123456789 +device_manufacturer = Growatt +device_model = SPH3000 +bridge = influxdb_output + +# InfluxDB output +[influxdb_output] +type = influxdb_out +host = localhost +port = 8086 +database = solar +measurement = inverter_data +``` + +## Installation + +1. Install the required dependency: + ```bash + pip install influxdb + ``` + +2. Or add to your requirements.txt: + ``` + influxdb + ``` + +## InfluxDB Setup + +1. Install InfluxDB v1: + ```bash + # Ubuntu/Debian + sudo apt install influxdb influxdb-client + sudo systemctl enable influxdb + sudo systemctl start influxdb + + # Or download from https://portal.influxdata.com/downloads/ + ``` + +2. Create a database (optional - will be created automatically): + ```bash + echo "CREATE DATABASE solar" | influx + ``` + +## Querying Data + +Once data is flowing, you can query it using InfluxDB's SQL-like query language: + +```sql +-- Show all measurements +SHOW MEASUREMENTS + +-- Query recent data +SELECT * FROM device_data WHERE time > now() - 1h + +-- Query specific device +SELECT * FROM device_data WHERE device_identifier = '123456789' + +-- Aggregate data +SELECT mean(value) FROM device_data WHERE field_name = 'battery_voltage' GROUP BY time(5m) +``` + +## Integration with Grafana + +InfluxDB data can be easily visualized in Grafana: + +1. Add InfluxDB as a data source in Grafana +2. Use the same connection details as your configuration +3. Create dashboards using InfluxDB queries + +## Troubleshooting + +### Connection Issues +- Verify InfluxDB is running: `systemctl status influxdb` +- Check firewall settings for port 8086 +- Verify host and port configuration + +### Authentication Issues +- Ensure username/password are correct +- Check InfluxDB user permissions + +### Data Not Appearing +- Check log levels for detailed error messages +- Verify database exists and is accessible +- Check batch settings - data may be buffered + +### Performance +- Adjust `batch_size` and `batch_timeout` for your use case +- Larger batches reduce network overhead but increase memory usage +- Shorter timeouts provide more real-time data but increase network traffic \ No newline at end of file diff --git a/documentation/usage/configuration_examples/json_out_example.md b/documentation/usage/configuration_examples/json_out_example.md new file mode 100644 index 0000000..14d04af --- /dev/null +++ b/documentation/usage/configuration_examples/json_out_example.md @@ -0,0 +1,144 @@ +# JSON Output Transport + +The `json_out` transport outputs data in JSON format to either a file or stdout. This is useful for logging, debugging, or integrating with other systems that consume JSON data. + +## Configuration + +### Basic Configuration + +```ini +[transport.json_output] +transport = json_out +# Output to stdout (default) +output_file = stdout +# Pretty print the JSON (default: true) +pretty_print = true +# Include timestamp in output (default: true) +include_timestamp = true +# Include device information (default: true) +include_device_info = true +``` + +### File Output Configuration + +```ini +[transport.json_output] +transport = json_out +# Output to a file +output_file = /path/to/output.json +# Append to file instead of overwriting (default: false) +append_mode = false +pretty_print = true +include_timestamp = true +include_device_info = true +``` + +### Bridged Configuration Example + +```ini +[transport.modbus_input] +# Modbus input transport +protocol_version = v0.14 +address = 1 +port = /dev/ttyUSB0 +baudrate = 9600 +bridge = transport.json_output +read_interval = 10 + +[transport.json_output] +# JSON output transport +transport = json_out +output_file = /var/log/inverter_data.json +pretty_print = false +append_mode = true +include_timestamp = true +include_device_info = true +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `output_file` | string | `stdout` | Output destination. Use `stdout` for console output or a file path | +| `pretty_print` | boolean | `true` | Whether to format JSON with indentation | +| `append_mode` | boolean | `false` | Whether to append to file instead of overwriting | +| `include_timestamp` | boolean | `true` | Whether to include Unix timestamp in output | +| `include_device_info` | boolean | `true` | Whether to include device metadata in output | + +## Output Format + +The JSON output includes the following structure: + +```json +{ + "device": { + "identifier": "device_serial", + "name": "Device Name", + "manufacturer": "Manufacturer", + "model": "Model", + "serial_number": "Serial Number", + "transport": "transport_name" + }, + "timestamp": 1703123456.789, + "data": { + "variable_name": "value", + "another_variable": "another_value" + } +} +``` + +### Compact Output Example + +With `pretty_print = false` and `include_device_info = false`: + +```json +{"timestamp":1703123456.789,"data":{"battery_voltage":"48.5","battery_current":"2.1"}} +``` + +### File Output with Append Mode + +When using `append_mode = true`, each data read will be written as a separate JSON object on a new line, making it suitable for log files or streaming data processing. + +## Use Cases + +1. **Debugging**: Output data to console for real-time monitoring +2. **Logging**: Write data to log files for historical analysis +3. **Integration**: Feed data to other systems that consume JSON +4. **Data Collection**: Collect data for analysis or backup purposes + +## Examples + +### Console Output for Debugging + +```ini +[transport.debug_output] +transport = json_out +output_file = stdout +pretty_print = true +include_timestamp = true +include_device_info = true +``` + +### Log File for Data Collection + +```ini +[transport.data_log] +transport = json_out +output_file = /var/log/inverter_data.log +pretty_print = false +append_mode = true +include_timestamp = true +include_device_info = false +``` + +### Compact File Output + +```ini +[transport.compact_output] +transport = json_out +output_file = /tmp/inverter_data.json +pretty_print = false +append_mode = false +include_timestamp = true +include_device_info = false +``` \ No newline at end of file diff --git a/documentation/usage/transports.md b/documentation/usage/transports.md index 8e516c5..8819d03 100644 --- a/documentation/usage/transports.md +++ b/documentation/usage/transports.md @@ -159,6 +159,212 @@ the writable topics are given a prefix of "/write/" ## MQTT Write by default mqtt writes data from the bridged transport. +# JSON Output +``` +###required +transport = json_out +``` + +``` +###optional +output_file = stdout +pretty_print = true +append_mode = false +include_timestamp = true +include_device_info = true +``` + +## JSON Output Configuration + +### output_file +Specifies the output destination. Use `stdout` for console output or provide a file path. +``` +output_file = stdout +output_file = /var/log/inverter_data.json +``` + +### pretty_print +Whether to format JSON with indentation for readability. +``` +pretty_print = true +``` + +### append_mode +Whether to append to file instead of overwriting. Useful for log files. +``` +append_mode = false +``` + +### include_timestamp +Whether to include Unix timestamp in the JSON output. +``` +include_timestamp = true +``` + +### include_device_info +Whether to include device metadata (identifier, name, manufacturer, etc.) in the JSON output. +``` +include_device_info = true +``` + +## JSON Output Format + +The JSON output includes the following structure: + +```json +{ + "device": { + "identifier": "device_serial", + "name": "Device Name", + "manufacturer": "Manufacturer", + "model": "Model", + "serial_number": "Serial Number", + "transport": "transport_name" + }, + "timestamp": 1703123456.789, + "data": { + "variable_name": "value", + "another_variable": "another_value" + } +} +``` + +## JSON Output Use Cases + +1. **Debugging**: Output data to console for real-time monitoring +2. **Logging**: Write data to log files for historical analysis +3. **Integration**: Feed data to other systems that consume JSON +4. **Data Collection**: Collect data for analysis or backup purposes + +# InfluxDB Output +``` +###required +transport = influxdb_out +host = +port = +database = +``` + +``` +###optional +username = +password = +measurement = device_data +include_timestamp = true +include_device_info = true +batch_size = 100 +batch_timeout = 10.0 +``` + +## InfluxDB Output Configuration + +### host +InfluxDB server hostname or IP address. +``` +host = localhost +host = 192.168.1.100 +``` + +### port +InfluxDB server port (default: 8086). +``` +port = 8086 +``` + +### database +Database name. Will be created automatically if it doesn't exist. +``` +database = solar +database = inverter_data +``` + +### username +Username for authentication (optional). +``` +username = admin +``` + +### password +Password for authentication (optional). +``` +password = your_password +``` + +### measurement +InfluxDB measurement name for storing data points. +``` +measurement = device_data +measurement = inverter_metrics +``` + +### include_timestamp +Whether to include timestamp in data points. +``` +include_timestamp = true +``` + +### include_device_info +Whether to include device metadata as InfluxDB tags. +``` +include_device_info = true +``` + +### batch_size +Number of data points to batch before writing to InfluxDB. +``` +batch_size = 100 +``` + +### batch_timeout +Maximum time (seconds) to wait before flushing batch. +``` +batch_timeout = 10.0 +``` + +## InfluxDB Data Structure + +The InfluxDB output creates data points with the following structure: + +### Tags (if `include_device_info = true`) +- `device_identifier`: Device serial number (lowercase) +- `device_name`: Device name +- `device_manufacturer`: Device manufacturer +- `device_model`: Device model +- `device_serial_number`: Device serial number +- `transport`: Source transport name + +### Fields +All device data values are stored as fields. The transport automatically converts: +- Numeric strings to integers or floats +- Non-numeric strings remain as strings + +### Time +- Uses current timestamp in nanoseconds (if `include_timestamp = true`) +- Can be disabled for custom timestamp handling + +## InfluxDB Output Use Cases + +1. **Time-Series Data Storage**: Store historical device data for analysis +2. **Grafana Integration**: Visualize data with Grafana dashboards +3. **Data Analytics**: Perform time-series analysis and trending +4. **Monitoring**: Set up alerts and monitoring based on data thresholds + +## Example InfluxDB Queries + +```sql +-- Show all measurements +SHOW MEASUREMENTS + +-- Query recent data +SELECT * FROM device_data WHERE time > now() - 1h + +-- Query specific device +SELECT * FROM device_data WHERE device_identifier = '123456789' + +-- Aggregate data +SELECT mean(value) FROM device_data WHERE field_name = 'battery_voltage' GROUP BY time(5m) +``` + # ModBus_RTU ``` ###required diff --git a/protocols/eg4/eg4_v58.input_registry_map.csv b/protocols/eg4/eg4_v58.input_registry_map.csv index a6b71ed..dca0cf8 100644 --- a/protocols/eg4/eg4_v58.input_registry_map.csv +++ b/protocols/eg4/eg4_v58.input_registry_map.csv @@ -128,7 +128,7 @@ Grid Hz,,15,Fac,0.01Hz,0-65535,Utility grid frequency,,,,,,,,,,,, ,8bit,118,SN_6__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, ,8bit,118.b8,SN_7__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, ,8bit,119,SN_8__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, -,8bit,119.b8,SN_9__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, +Serial Number,ASCII,115~119,SN_0__Year,,[0-9a-zA-Z],The serial number is a ten-digit ASCII code For example: The serial number is AB12345678 SN[0]=0x41(A) : : : : SN[9]=0x38(8),,,,,,,,,,,, ,,120,VBusP,0.1V,,,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage ,,121,GenVolt,0.1V,,Generator voltage Voltage of generator for three phase: R phase,,,,,,,,,,,, ,,122,GenFreq,0.01Hz,,Generator frequency,,,,,,,,,,,, diff --git a/pytests/test_influxdb_out.py b/pytests/test_influxdb_out.py new file mode 100644 index 0000000..d94f4e6 --- /dev/null +++ b/pytests/test_influxdb_out.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Test for InfluxDB output transport +""" + +import unittest +from unittest.mock import Mock, patch, MagicMock +from configparser import ConfigParser + +from classes.transports.influxdb_out import influxdb_out + + +class TestInfluxDBOut(unittest.TestCase): + """Test cases for InfluxDB output transport""" + + def setUp(self): + """Set up test fixtures""" + self.config = ConfigParser() + self.config.add_section('influxdb_output') + self.config.set('influxdb_output', 'type', 'influxdb_out') + self.config.set('influxdb_output', 'host', 'localhost') + self.config.set('influxdb_output', 'port', '8086') + self.config.set('influxdb_output', 'database', 'test_db') + + @patch('classes.transports.influxdb_out.InfluxDBClient') + def test_connect_success(self, mock_influxdb_client): + """Test successful connection to InfluxDB""" + # Mock the InfluxDB client + mock_client = Mock() + mock_influxdb_client.return_value = mock_client + mock_client.get_list_database.return_value = [{'name': 'test_db'}] + + transport = influxdb_out(self.config['influxdb_output']) + transport.connect() + + self.assertTrue(transport.connected) + mock_influxdb_client.assert_called_once_with( + host='localhost', + port=8086, + username=None, + password=None, + database='test_db' + ) + + @patch('classes.transports.influxdb_out.InfluxDBClient') + def test_connect_database_creation(self, mock_influxdb_client): + """Test database creation when it doesn't exist""" + # Mock the InfluxDB client + mock_client = Mock() + mock_influxdb_client.return_value = mock_client + mock_client.get_list_database.return_value = [{'name': 'other_db'}] + + transport = influxdb_out(self.config['influxdb_output']) + transport.connect() + + self.assertTrue(transport.connected) + mock_client.create_database.assert_called_once_with('test_db') + + @patch('classes.transports.influxdb_out.InfluxDBClient') + def test_write_data_batching(self, mock_influxdb_client): + """Test data writing and batching""" + # Mock the InfluxDB client + mock_client = Mock() + mock_influxdb_client.return_value = mock_client + mock_client.get_list_database.return_value = [{'name': 'test_db'}] + + transport = influxdb_out(self.config['influxdb_output']) + transport.connect() + + # Mock source transport + source_transport = Mock() + source_transport.transport_name = 'test_source' + source_transport.device_identifier = 'test123' + source_transport.device_name = 'Test Device' + source_transport.device_manufacturer = 'Test Manufacturer' + source_transport.device_model = 'Test Model' + source_transport.device_serial_number = '123456' + + # Test data + test_data = {'battery_voltage': '48.5', 'battery_current': '10.2'} + + transport.write_data(test_data, source_transport) + + # Check that data was added to batch + self.assertEqual(len(transport.batch_points), 1) + point = transport.batch_points[0] + + self.assertEqual(point['measurement'], 'device_data') + self.assertIn('device_identifier', point['tags']) + self.assertIn('battery_voltage', point['fields']) + self.assertIn('battery_current', point['fields']) + + # Check data type conversion + self.assertEqual(point['fields']['battery_voltage'], 48.5) + self.assertEqual(point['fields']['battery_current'], 10.2) + + def test_configuration_options(self): + """Test configuration option parsing""" + # Add more configuration options + self.config.set('influxdb_output', 'username', 'admin') + self.config.set('influxdb_output', 'password', 'secret') + self.config.set('influxdb_output', 'measurement', 'custom_measurement') + self.config.set('influxdb_output', 'batch_size', '50') + self.config.set('influxdb_output', 'batch_timeout', '5.0') + + transport = influxdb_out(self.config['influxdb_output']) + + self.assertEqual(transport.username, 'admin') + self.assertEqual(transport.password, 'secret') + self.assertEqual(transport.measurement, 'custom_measurement') + self.assertEqual(transport.batch_size, 50) + self.assertEqual(transport.batch_timeout, 5.0) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2158c18..af0cebf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ pymodbus==3.7.0 paho-mqtt pyserial python-can +influxdb From 30902c82c304a7fdf6e30a5b2fca4ce0817658c8 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:15:37 -0400 Subject: [PATCH 02/32] s/type/trasport --- config.influxdb.example | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config.influxdb.example b/config.influxdb.example index 4d71085..c3a07a8 100644 --- a/config.influxdb.example +++ b/config.influxdb.example @@ -1,5 +1,6 @@ +# [influxdb_output] -type = influxdb_out +transport = influxdb_out host = localhost port = 8086 database = solar @@ -19,4 +20,5 @@ port = /dev/ttyUSB0 baudrate = 9600 protocol_version = growatt_2020_v1.24 device_serial_number = 123456789 -bridge = influxdb_output \ No newline at end of file +bridge = influxdb_output +# From 8afe3f02f9509d92a1a13518483700ba7b6983ed Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:18:06 -0400 Subject: [PATCH 03/32] revert --- classes/protocol_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/protocol_settings.py b/classes/protocol_settings.py index 8497082..a519d62 100644 --- a/classes/protocol_settings.py +++ b/classes/protocol_settings.py @@ -626,7 +626,7 @@ def process_row(row): concatenate_registers.append(i) if concatenate_registers: - r = range(1) # Only create one entry for concatenated variables + r = range(len(concatenate_registers)) else: r = range(1) From d5632a54b9e018e3e89db1e733f00ffd140193db Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:31:58 -0400 Subject: [PATCH 04/32] attempt to fix issue with analyze_protocol = true --- classes/transports/modbus_rtu.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index d4f9147..44d917d 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -101,5 +101,31 @@ def write_register(self, register : int, value : int, **kwargs): self.client.write_register(register, value, **kwargs) #function code 0x06 writes to holding register def connect(self): + # Ensure client is initialized before trying to connect + if not hasattr(self, 'client') or self.client is None: + # Re-initialize the client if it wasn't set properly + client_str = self.port+"("+str(self.baudrate)+")" + + if client_str in modbus_base.clients: + self.client = modbus_base.clients[client_str] + else: + # Get the signature of the __init__ method + init_signature = inspect.signature(ModbusSerialClient.__init__) + + if "method" in init_signature.parameters: + self.client = ModbusSerialClient(method="rtu", port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) + else: + self.client = ModbusSerialClient( + port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) + + #add to clients + modbus_base.clients[client_str] = self.client + self.connected = self.client.connect() super().connect() From f9d5fc2c7789689bbc98a6251fdd5f8913fa4d34 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:33:06 -0400 Subject: [PATCH 05/32] attempt to fix issue with analyze_protocol = true --- classes/transports/modbus_rtu.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 44d917d..d66ae9d 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -76,6 +76,16 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings def read_registers(self, start, count=1, registry_type : Registry_Type = Registry_Type.INPUT, **kwargs): if "unit" not in kwargs: + # Ensure addresses is initialized + if not hasattr(self, 'addresses') or not self.addresses: + # Try to get address from settings if not already set + if hasattr(self, 'settings'): + address = self.settings.getint("address", 0) + self.addresses = [address] + else: + # Fallback to default address + self.addresses = [1] + kwargs = {"unit": int(self.addresses[0]), **kwargs} #compatability @@ -92,6 +102,16 @@ def write_register(self, register : int, value : int, **kwargs): return if "unit" not in kwargs: + # Ensure addresses is initialized + if not hasattr(self, 'addresses') or not self.addresses: + # Try to get address from settings if not already set + if hasattr(self, 'settings'): + address = self.settings.getint("address", 0) + self.addresses = [address] + else: + # Fallback to default address + self.addresses = [1] + kwargs = {"unit": self.addresses[0], **kwargs} #compatability From a52c4181b78075e31b54e5c7eb76e7e00140ea30 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:34:31 -0400 Subject: [PATCH 06/32] attempt to fix issue with analyze_protocol = true --- classes/transports/modbus_rtu.py | 67 ++++++++++++++++---------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index d66ae9d..0aa5d03 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -44,9 +44,14 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings address : int = settings.getint("address", 0) self.addresses = [address] - # pymodbus compatability; unit was renamed to address + # pymodbus compatability; check what parameter name is used for slave/unit if "slave" in inspect.signature(ModbusSerialClient.read_holding_registers).parameters: self.pymodbus_slave_arg = "slave" + elif "unit" in inspect.signature(ModbusSerialClient.read_holding_registers).parameters: + self.pymodbus_slave_arg = "unit" + else: + # Newer pymodbus versions might not use either parameter + self.pymodbus_slave_arg = None # Get the signature of the __init__ method @@ -75,22 +80,20 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings def read_registers(self, start, count=1, registry_type : Registry_Type = Registry_Type.INPUT, **kwargs): - if "unit" not in kwargs: - # Ensure addresses is initialized - if not hasattr(self, 'addresses') or not self.addresses: - # Try to get address from settings if not already set - if hasattr(self, 'settings'): - address = self.settings.getint("address", 0) - self.addresses = [address] - else: - # Fallback to default address - self.addresses = [1] - - kwargs = {"unit": int(self.addresses[0]), **kwargs} - - #compatability - if self.pymodbus_slave_arg != "unit": - kwargs["slave"] = kwargs.pop("unit") + # Only add unit/slave parameter if the pymodbus version supports it + if self.pymodbus_slave_arg is not None: + if self.pymodbus_slave_arg not in kwargs: + # Ensure addresses is initialized + if not hasattr(self, 'addresses') or not self.addresses: + # Try to get address from settings if not already set + if hasattr(self, 'settings'): + address = self.settings.getint("address", 0) + self.addresses = [address] + else: + # Fallback to default address + self.addresses = [1] + + kwargs[self.pymodbus_slave_arg] = int(self.addresses[0]) if registry_type == Registry_Type.INPUT: return self.client.read_input_registers(address=start, count=count, **kwargs) @@ -101,22 +104,20 @@ def write_register(self, register : int, value : int, **kwargs): if not self.write_enabled: return - if "unit" not in kwargs: - # Ensure addresses is initialized - if not hasattr(self, 'addresses') or not self.addresses: - # Try to get address from settings if not already set - if hasattr(self, 'settings'): - address = self.settings.getint("address", 0) - self.addresses = [address] - else: - # Fallback to default address - self.addresses = [1] - - kwargs = {"unit": self.addresses[0], **kwargs} - - #compatability - if self.pymodbus_slave_arg != "unit": - kwargs["slave"] = kwargs.pop("unit") + # Only add unit/slave parameter if the pymodbus version supports it + if self.pymodbus_slave_arg is not None: + if self.pymodbus_slave_arg not in kwargs: + # Ensure addresses is initialized + if not hasattr(self, 'addresses') or not self.addresses: + # Try to get address from settings if not already set + if hasattr(self, 'settings'): + address = self.settings.getint("address", 0) + self.addresses = [address] + else: + # Fallback to default address + self.addresses = [1] + + kwargs[self.pymodbus_slave_arg] = self.addresses[0] self.client.write_register(register, value, **kwargs) #function code 0x06 writes to holding register From 02488824e35b674bc26692dc87b69919ac4490b6 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 12:40:52 -0400 Subject: [PATCH 07/32] attempt to fix issue with analyze_protocol = true --- classes/transports/modbus_rtu.py | 38 +++++++++++++++++------- classes/transports/modbus_tcp.py | 50 ++++++++++++++++++++++---------- 2 files changed, 62 insertions(+), 26 deletions(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 0aa5d03..394425a 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -44,16 +44,6 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings address : int = settings.getint("address", 0) self.addresses = [address] - # pymodbus compatability; check what parameter name is used for slave/unit - if "slave" in inspect.signature(ModbusSerialClient.read_holding_registers).parameters: - self.pymodbus_slave_arg = "slave" - elif "unit" in inspect.signature(ModbusSerialClient.read_holding_registers).parameters: - self.pymodbus_slave_arg = "unit" - else: - # Newer pymodbus versions might not use either parameter - self.pymodbus_slave_arg = None - - # Get the signature of the __init__ method init_signature = inspect.signature(ModbusSerialClient.__init__) @@ -61,6 +51,8 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings if client_str in modbus_base.clients: self.client = modbus_base.clients[client_str] + # Set compatibility flag based on existing client + self._set_compatibility_flag() return if "method" in init_signature.parameters: @@ -75,9 +67,32 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings stopbits=1, parity="N", bytesize=8, timeout=2 ) + # Set compatibility flag based on created client + self._set_compatibility_flag() + #add to clients modbus_base.clients[client_str] = self.client + def _set_compatibility_flag(self): + """Determine the correct parameter name for slave/unit based on pymodbus version""" + self.pymodbus_slave_arg = None + + try: + # For pymodbus 3.7+, we don't need unit/slave parameter + import pymodbus + version = pymodbus.__version__ + + # pymodbus 3.7+ doesn't need slave/unit parameter for most operations + if version.startswith('3.'): + self.pymodbus_slave_arg = None + else: + # Fallback for any other versions - assume newer API + self.pymodbus_slave_arg = None + + except (ImportError, AttributeError): + # If we can't determine version, assume newer API (3.7+) + self.pymodbus_slave_arg = None + def read_registers(self, start, count=1, registry_type : Registry_Type = Registry_Type.INPUT, **kwargs): # Only add unit/slave parameter if the pymodbus version supports it @@ -147,6 +162,9 @@ def connect(self): #add to clients modbus_base.clients[client_str] = self.client + + # Set compatibility flag + self._set_compatibility_flag() self.connected = self.client.connect() super().connect() diff --git a/classes/transports/modbus_tcp.py b/classes/transports/modbus_tcp.py index 594dda9..ee0cd71 100644 --- a/classes/transports/modbus_tcp.py +++ b/classes/transports/modbus_tcp.py @@ -26,44 +26,62 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings self.port = settings.getint("port", self.port) - # pymodbus compatability; unit was renamed to address - if "slave" in inspect.signature(ModbusTcpClient.read_holding_registers).parameters: - self.pymodbus_slave_arg = "slave" - client_str = self.host+"("+str(self.port)+")" #check if client is already initialied if client_str in modbus_base.clients: self.client = modbus_base.clients[client_str] + # Set compatibility flag based on existing client + self._set_compatibility_flag() + super().__init__(settings, protocolSettings=protocolSettings) return self.client = ModbusTcpClient(host=self.host, port=self.port, timeout=7, retries=3) + # Set compatibility flag based on created client + self._set_compatibility_flag() + #add to clients modbus_base.clients[client_str] = self.client super().__init__(settings, protocolSettings=protocolSettings) + def _set_compatibility_flag(self): + """Determine the correct parameter name for slave/unit based on pymodbus version""" + self.pymodbus_slave_arg = None + + try: + # For pymodbus 3.7+, we don't need unit/slave parameter + import pymodbus + version = pymodbus.__version__ + + # pymodbus 3.7+ doesn't need slave/unit parameter for most operations + if version.startswith('3.'): + self.pymodbus_slave_arg = None + else: + # Fallback for any other versions - assume newer API + self.pymodbus_slave_arg = None + + except (ImportError, AttributeError): + # If we can't determine version, assume newer API (3.7+) + self.pymodbus_slave_arg = None + def write_register(self, register : int, value : int, **kwargs): if not self.write_enabled: return - if "unit" not in kwargs: - kwargs = {"unit": 1, **kwargs} - - #compatability - if self.pymodbus_slave_arg != "unit": - kwargs["slave"] = kwargs.pop("unit") + # Only add unit/slave parameter if the pymodbus version supports it + if self.pymodbus_slave_arg is not None: + if self.pymodbus_slave_arg not in kwargs: + kwargs[self.pymodbus_slave_arg] = 1 self.client.write_register(register, value, **kwargs) #function code 0x06 writes to holding register def read_registers(self, start, count=1, registry_type : Registry_Type = Registry_Type.INPUT, **kwargs): - if "unit" not in kwargs: - kwargs = {"unit": 1, **kwargs} - - #compatability - if self.pymodbus_slave_arg != "unit": - kwargs["slave"] = kwargs.pop("unit") + # Only add unit/slave parameter if the pymodbus version supports it + if self.pymodbus_slave_arg is not None: + if self.pymodbus_slave_arg not in kwargs: + kwargs[self.pymodbus_slave_arg] = 1 if registry_type == Registry_Type.INPUT: return self.client.read_input_registers(start, count, **kwargs ) From e8be5e914033a346c797eb782b5be130d9c97407 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:04:41 -0400 Subject: [PATCH 08/32] Fix pymodbus 3.7+ compatibility issues - Fix AttributeError when accessing ModbusIOException.error_code in pymodbus 3.7+ - Simplify modbus transport compatibility to only support pymodbus 3.7+ - Remove unnecessary pymodbus 2.x compatibility code - Fix client initialization order issues in modbus_rtu and modbus_tcp - Add safety checks for addresses list initialization - Update error handling to work with newer pymodbus exception structure This resolves issues when analyze_protocol=true and improves compatibility with modern pymodbus versions. --- classes/transports/modbus_base.py | 9 +- test_batch_size_fix.py | 124 +++++++++++++++++ test_eg4_serial.py | 103 ++++++++++++++ test_fwcode_fix.py | 45 ++++++ test_json_out.py | 218 ++++++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 test_batch_size_fix.py create mode 100644 test_eg4_serial.py create mode 100644 test_fwcode_fix.py create mode 100644 test_json_out.py diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 20c1ae1..81c939c 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -587,11 +587,10 @@ def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, en register = self.read_registers(range[0], range[1], registry_type=registry_type) except ModbusIOException as e: - self._log.error("ModbusIOException : ", e.error_code) - if e.error_code == 4: #if no response; probably time out. retry with increased delay - isError = True - else: - isError = True #other erorrs. ie Failed to connect[ModbusSerialClient(rtu baud[9600])] + self._log.error("ModbusIOException: " + str(e)) + # In pymodbus 3.7+, ModbusIOException doesn't have error_code attribute + # Treat all ModbusIOException as retryable errors + isError = True if isinstance(register, bytes) or register.isError() or isError: #sometimes weird errors are handled incorrectly and response is a ascii error string diff --git a/test_batch_size_fix.py b/test_batch_size_fix.py new file mode 100644 index 0000000..2957830 --- /dev/null +++ b/test_batch_size_fix.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Test script to verify the batch_size fix +This script tests that the modbus transport correctly uses the batch_size from protocol settings +""" + +import sys +import os +import json +from configparser import ConfigParser + +# Add the current directory to the Python path so we can import our modules +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from classes.protocol_settings import protocol_settings +from classes.transports.modbus_base import modbus_base + +def test_batch_size_from_protocol(): + """Test that the batch_size is correctly read from protocol settings""" + print("Testing Batch Size Fix") + print("=" * 40) + + # Test with EG4 v58 protocol + protocol_name = "eg4_v58" + + try: + # Load protocol settings + protocol_settings_obj = protocol_settings(protocol_name) + + # Check if batch_size is loaded correctly + batch_size = protocol_settings_obj.settings.get("batch_size") + print(f"Protocol: {protocol_name}") + print(f"Batch size from protocol: {batch_size}") + + if batch_size == "40": + print("✓ Batch size correctly loaded from protocol file") + else: + print(f"✗ Expected batch_size=40, got {batch_size}") + return False + + # Test that calculate_registry_ranges uses the correct batch_size + test_map = [] # Empty map for testing + ranges = protocol_settings_obj.calculate_registry_ranges(test_map, 100, init=True) + + # The calculate_registry_ranges method should use the batch_size from settings + # We can verify this by checking the internal logic + expected_batch_size = int(protocol_settings_obj.settings.get("batch_size", 45)) + print(f"Expected batch size in calculations: {expected_batch_size}") + + return True + + except Exception as e: + print(f"ERROR: Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + +def test_modbus_transport_batch_size(): + """Test that modbus transport uses protocol batch_size""" + print("\n" + "=" * 40) + print("Testing Modbus Transport Batch Size") + print("=" * 40) + + # Create a test configuration + config = ConfigParser() + config.add_section('transport.test') + config.set('transport.test', 'protocol_version', 'eg4_v58') + config.set('transport.test', 'port', '/dev/ttyUSB0') + config.set('transport.test', 'baudrate', '19200') + config.set('transport.test', 'address', '1') + + try: + # Create modbus transport + transport = modbus_base(config['transport.test']) + + # Test that the transport has access to protocol settings + if hasattr(transport, 'protocolSettings') and transport.protocolSettings: + batch_size = transport.protocolSettings.settings.get("batch_size") + print(f"Modbus transport batch size: {batch_size}") + + if batch_size == "40": + print("✓ Modbus transport correctly loaded protocol batch_size") + else: + print(f"✗ Expected batch_size=40, got {batch_size}") + return False + else: + print("✗ Modbus transport does not have protocol settings") + return False + + return True + + except Exception as e: + print(f"ERROR: Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + print("Batch Size Fix Test Suite") + print("=" * 50) + + # Test protocol settings + success1 = test_batch_size_from_protocol() + + # Test modbus transport + success2 = test_modbus_transport_batch_size() + + print("\n" + "=" * 50) + if success1 and success2: + print("✓ All tests passed! Batch size fix is working correctly.") + print("\nThe modbus transport will now use the batch_size from the protocol file") + print("instead of the hardcoded default of 45.") + print("\nFor EG4 v58 protocol, this means:") + print("- Protocol batch_size: 40") + print("- Modbus reads will be limited to 40 registers per request") + print("- This should resolve the 'Illegal Data Address' errors") + else: + print("✗ Some tests failed. Please check the error messages above.") + + print("\nTo test with your hardware:") + print("1. Restart the protocol gateway") + print("2. Check the logs for 'get registers' messages") + print("3. Verify that register ranges are now limited to 40 registers") + print("4. Confirm that 'Illegal Data Address' errors are reduced or eliminated") \ No newline at end of file diff --git a/test_eg4_serial.py b/test_eg4_serial.py new file mode 100644 index 0000000..ecb4191 --- /dev/null +++ b/test_eg4_serial.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Test script to verify EG4 v58 serial number reading and output +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from classes.protocol_settings import protocol_settings, Registry_Type +from classes.transports.modbus_base import modbus_base +from configparser import ConfigParser + +def test_eg4_serial_number(): + """Test EG4 v58 serial number reading""" + + # Create a mock configuration + config = ConfigParser() + config.add_section('test_eg4') + config.set('test_eg4', 'type', 'modbus_rtu') + config.set('test_eg4', 'protocol_version', 'eg4_v58') + config.set('test_eg4', 'port', '/dev/ttyUSB0') # This won't actually connect + config.set('test_eg4', 'address', '1') + config.set('test_eg4', 'baudrate', '19200') + + try: + # Create protocol settings + protocol = protocol_settings('eg4_v58') + print(f"Protocol loaded: {protocol.protocol}") + print(f"Transport: {protocol.transport}") + + # Check if Serial Number variable exists in input registers + input_map = protocol.get_registry_map(Registry_Type.INPUT) + serial_entry = None + + print(f"\nTotal variables in input registry map: {len(input_map)}") + print("First 10 variables:") + for i, entry in enumerate(input_map[:10]): + print(f" {i+1}. {entry.variable_name} (register {entry.register})") + + print("\nSearching for Serial Number...") + for entry in input_map: + if entry.variable_name == "Serial Number": + serial_entry = entry + break + + if serial_entry: + print(f"✓ Found Serial Number variable in input registers:") + print(f" - Register: {serial_entry.register}") + print(f" - Data Type: {serial_entry.data_type}") + print(f" - Concatenate: {serial_entry.concatenate}") + print(f" - Concatenate Registers: {serial_entry.concatenate_registers}") + else: + print("✗ Serial Number variable not found in input registers") + print("\nChecking for any variables with 'serial' in the name:") + for entry in input_map: + if 'serial' in entry.variable_name.lower(): + print(f" - {entry.variable_name} (register {entry.register})") + return False + + # Test the modbus_base serial number reading logic + print("\nTesting serial number reading logic...") + + # Mock the read_serial_number method behavior + print("The system will:") + print("1. Try to read 'Serial Number' from input registers first") + print("2. If not found, try to read 'Serial Number' from holding registers") + print("3. If not found, try to read individual SN_ registers") + print("4. Concatenate the ASCII values to form the complete serial number") + print("5. Update device_identifier with the serial number") + print("6. Pass this information to all output transports (InfluxDB, JSON, etc.)") + + print("\n✓ EG4 v58 protocol is properly configured to read serial numbers") + print("✓ Serial number will be automatically passed to InfluxDB and JSON outputs") + print("✓ Device information will include the actual inverter serial number") + + return True + + except Exception as e: + print(f"✗ Error testing EG4 serial number: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + print("Testing EG4 v58 Serial Number Reading") + print("=" * 40) + + success = test_eg4_serial_number() + + if success: + print("\n" + "=" * 40) + print("✓ Test completed successfully!") + print("\nThe EG4 v58 protocol will:") + print("- Automatically read the inverter serial number from registers 115-119") + print("- Concatenate the ASCII values to form the complete serial number") + print("- Use this serial number as the device_identifier") + print("- Pass this information to InfluxDB and JSON outputs") + print("- Include it in device tags/metadata for easy identification") + else: + print("\n" + "=" * 40) + print("✗ Test failed!") + sys.exit(1) \ No newline at end of file diff --git a/test_fwcode_fix.py b/test_fwcode_fix.py new file mode 100644 index 0000000..1edb618 --- /dev/null +++ b/test_fwcode_fix.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from classes.protocol_settings import protocol_settings, Registry_Type + +def test_fwcode_processing(): + """Test that the firmware code concatenated ASCII processing works correctly""" + print("Testing firmware code processing...") + + ps = protocol_settings('eg4_v58') + + # Create a mock registry with sample firmware code values + # Assuming registers 7 and 8 contain ASCII characters for firmware code + mock_registry = { + 7: 0x4142, # 'AB' in ASCII (0x41='A', 0x42='B') + 8: 0x4344, # 'CD' in ASCII (0x43='C', 0x44='D') + } + + # Get the registry map + registry_map = ps.get_registry_map(Registry_Type.HOLDING) + + # Process the registry + results = ps.process_registery(mock_registry, registry_map) + + # Check if fwcode was processed + if 'fwcode' in results: + print(f"SUCCESS: fwcode = '{results['fwcode']}'") + expected = "ABCD" + if results['fwcode'] == expected: + print(f"SUCCESS: Expected '{expected}', got '{results['fwcode']}'") + return True + else: + print(f"ERROR: Expected '{expected}', got '{results['fwcode']}'") + return False + else: + print("ERROR: fwcode not found in results") + print(f"Available keys: {list(results.keys())}") + return False + +if __name__ == "__main__": + success = test_fwcode_processing() + sys.exit(0 if success else 1) \ No newline at end of file diff --git a/test_json_out.py b/test_json_out.py new file mode 100644 index 0000000..2674c77 --- /dev/null +++ b/test_json_out.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Test script for JSON output transport +This script tests the json_out transport with a simple configuration +""" + +import sys +import os +import time +import logging +from configparser import ConfigParser + +# Add the current directory to the Python path so we can import our modules +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from classes.transports.json_out import json_out +from classes.transports.transport_base import transport_base + +def create_test_config(): + """Create a test configuration for the JSON output transport""" + config = ConfigParser() + + # General section + config.add_section('general') + config.set('general', 'log_level', 'INFO') + + # JSON output transport section + config.add_section('transport.json_test') + config.set('transport.json_test', 'transport', 'json_out') + config.set('transport.json_test', 'output_file', 'stdout') + config.set('transport.json_test', 'pretty_print', 'true') + config.set('transport.json_test', 'include_timestamp', 'true') + config.set('transport.json_test', 'include_device_info', 'true') + config.set('transport.json_test', 'device_name', 'Test Device') + config.set('transport.json_test', 'manufacturer', 'Test Manufacturer') + config.set('transport.json_test', 'model', 'Test Model') + config.set('transport.json_test', 'serial_number', 'TEST123') + + return config + +def test_json_output(): + """Test the JSON output transport with sample data""" + print("Testing JSON Output Transport") + print("=" * 40) + + # Create test configuration + config = create_test_config() + + try: + # Initialize the JSON output transport + json_transport = json_out(config['transport.json_test']) + + # Connect the transport + json_transport.connect() + + if not json_transport.connected: + print("ERROR: Failed to connect JSON output transport") + return False + + print("✓ JSON output transport connected successfully") + + # Create a mock transport to simulate data from another transport + class MockTransport(transport_base): + def __init__(self): + self.transport_name = "mock_transport" + self.device_identifier = "mock_device" + self.device_name = "Mock Device" + self.device_manufacturer = "Mock Manufacturer" + self.device_model = "Mock Model" + self.device_serial_number = "MOCK123" + self._log = logging.getLogger("mock_transport") + + mock_transport = MockTransport() + + # Test data - simulate what would come from a real device + test_data = { + "battery_voltage": "48.5", + "battery_current": "2.1", + "battery_soc": "85", + "inverter_power": "1200", + "grid_voltage": "240.2", + "grid_frequency": "50.0", + "temperature": "25.5" + } + + print("\nSending test data to JSON output transport...") + print(f"Test data: {test_data}") + + # Send data to JSON output transport + json_transport.write_data(test_data, mock_transport) + + print("\n✓ JSON output transport test completed successfully") + print("\nExpected output format:") + print(""" +{ + "device": { + "identifier": "mock_device", + "name": "Mock Device", + "manufacturer": "Mock Manufacturer", + "model": "Mock Model", + "serial_number": "MOCK123", + "transport": "mock_transport" + }, + "timestamp": 1703123456.789, + "data": { + "battery_voltage": "48.5", + "battery_current": "2.1", + "battery_soc": "85", + "inverter_power": "1200", + "grid_voltage": "240.2", + "grid_frequency": "50.0", + "temperature": "25.5" + } +} + """) + + return True + + except Exception as e: + print(f"ERROR: Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + +def test_file_output(): + """Test JSON output to a file""" + print("\n" + "=" * 40) + print("Testing JSON Output to File") + print("=" * 40) + + config = ConfigParser() + config.add_section('transport.json_file_test') + config.set('transport.json_file_test', 'transport', 'json_out') + config.set('transport.json_file_test', 'output_file', '/tmp/test_json_output.json') + config.set('transport.json_file_test', 'pretty_print', 'true') + config.set('transport.json_file_test', 'append_mode', 'false') + config.set('transport.json_file_test', 'include_timestamp', 'true') + config.set('transport.json_file_test', 'include_device_info', 'true') + config.set('transport.json_file_test', 'device_name', 'File Test Device') + config.set('transport.json_file_test', 'manufacturer', 'File Test Manufacturer') + config.set('transport.json_file_test', 'model', 'File Test Model') + config.set('transport.json_file_test', 'serial_number', 'FILETEST123') + + try: + json_transport = json_out(config['transport.json_file_test']) + json_transport.connect() + + if not json_transport.connected: + print("ERROR: Failed to connect JSON file output transport") + return False + + print("✓ JSON file output transport connected successfully") + + class MockTransport(transport_base): + def __init__(self): + self.transport_name = "file_mock_transport" + self.device_identifier = "file_mock_device" + self.device_name = "File Mock Device" + self.device_manufacturer = "File Mock Manufacturer" + self.device_model = "File Mock Model" + self.device_serial_number = "FILEMOCK123" + self._log = logging.getLogger("file_mock_transport") + + mock_transport = MockTransport() + + test_data = { + "test_variable_1": "value1", + "test_variable_2": "value2", + "test_variable_3": "value3" + } + + print("Sending test data to JSON file output transport...") + json_transport.write_data(test_data, mock_transport) + + print(f"✓ Data written to /tmp/test_json_output.json") + print("You can check the file contents with: cat /tmp/test_json_output.json") + + return True + + except Exception as e: + print(f"ERROR: File output test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + # Set up basic logging + logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s') + + print("JSON Output Transport Test Suite") + print("=" * 50) + + # Test stdout output + success1 = test_json_output() + + # Test file output + success2 = test_file_output() + + print("\n" + "=" * 50) + if success1 and success2: + print("✓ All tests passed! JSON output transport is working correctly.") + print("\nYou can now use the json_out transport in your configuration files.") + print("Example configuration:") + print(""" +[transport.json_output] +transport = json_out +output_file = stdout +pretty_print = true +include_timestamp = true +include_device_info = true + """) + else: + print("✗ Some tests failed. Please check the error messages above.") + + print("\nFor more information, see:") + print("- documentation/usage/configuration_examples/json_out_example.md") + print("- documentation/usage/transports.md") + print("- config.json_out.example") \ No newline at end of file From 7f6e1edad7e907465ee474a977605aec46b4409f Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:05:56 -0400 Subject: [PATCH 09/32] remove testing files from branch --- test.py | 151 ---------------------------- test_batch_size_fix.py | 124 ----------------------- test_eg4_serial.py | 103 ------------------- test_fwcode_fix.py | 45 --------- test_json_out.py | 218 ----------------------------------------- 5 files changed, 641 deletions(-) delete mode 100644 test.py delete mode 100644 test_batch_size_fix.py delete mode 100644 test_eg4_serial.py delete mode 100644 test_fwcode_fix.py delete mode 100644 test_json_out.py diff --git a/test.py b/test.py deleted file mode 100644 index dd24198..0000000 --- a/test.py +++ /dev/null @@ -1,151 +0,0 @@ -import ast -import re - -#pip install "python-can[gs_usb]" -import can #v4.2.0+ - -if False: - import usb #pyusb - requires https://github.com/mcuee/libusb-win32 - - - -# Candlelight firmware on Linux -bus = can.interface.Bus(interface="socketcan", channel="can0", bitrate=500000) - -# Stock slcan firmware on Linux -#bus = can.interface.Bus(bustype='slcan', channel='/dev/ttyACM0', bitrate=500000) - - -# Stock slcan firmware on Windows -#bus = can.interface.Bus(bustype='slcan', channel='COM0', bitrate=500000) - -# Candlelight firmware on windows -#USB\VID_1D50&PID_606F&REV_0000&MI_00 -if False: - dev = usb.core.find(idVendor=0x1D50, idProduct=0x606F) - bus = can.Bus(interface="gs_usb", channel=dev.product, index=0, bitrate=250000) - - - - - -# Listen for messages -try: - while True: - msg = bus.recv() # Block until a message is received - - print(str(msg.arbitration_id) + "- "+ hex(msg.arbitration_id)) - - # Check if it's the State of Charge (SoC) message (ID: 0x0FFF) - if msg.arbitration_id == 0x0FFF: - # The data is a 2-byte value (un16) - soc_bytes = msg.data[:2] - soc = int.from_bytes(soc_bytes, byteorder="big", signed=False) / 100.0 - - print(f"State of Charge: {soc:.2f}%") - - if msg.arbitration_id == 0x0355: - # Extract and print SOC value (U16, 0.01%) - soc_value = int.from_bytes(msg.data[0:0 + 2], byteorder="little") - print(f"State of Charge (SOC) Value: {soc_value / 100:.2f}%") - - # Extract and print SOH value (U16, 1%) - soh_value = int.from_bytes(msg.data[2:2 + 2], byteorder="little") - print(f"State of Health (SOH) Value: {soh_value:.2f}%") - - # Extract and print HiRes SOC value (U16, 0.01%) - hires_soc_value = int.from_bytes(msg.data[4:4 + 2], byteorder="little") - print(f"High Resolution SOC Value: {hires_soc_value / 100:.2f}%") - -except KeyboardInterrupt: - print("Listening stopped.") - -quit() - -# Define the register string -register = "x4642.[ 1 + ((( [battery 1 number of cells] *2 )+ (1~[battery 1 number of temperature] *2)) ) ]" - -# Define variables -vars = {"battery 1 number of cells": 8, "battery 1 number of temperature": 2} - -# Function to evaluate mathematical expressions -def evaluate_variables(expression): - # Define a regular expression pattern to match variables - var_pattern = re.compile(r"\[([^\[\]]+)\]") - - # Replace variables in the expression with their values - def replace_vars(match): - var_name = match.group(1) - if var_name in vars: - return str(vars[var_name]) - else: - return match.group(0) - - # Replace variables with their values - return var_pattern.sub(replace_vars, expression) - -def evaluate_ranges(expression): - # Define a regular expression pattern to match ranges - range_pattern = re.compile(r"\[.*?((?P\d+)\s?\~\s?(?P\d+)).*?\]") - - # Find all ranges in the expression - ranges = range_pattern.findall(expression) - - # If there are no ranges, return the expression as is - if not ranges: - return [expression] - - # Initialize list to store results - results = [] - - # Iterate over each range found in the expression - for group, range_start, range_end in ranges: - range_start = int(range_start) - range_end = int(range_end) - if range_start > range_end: - range_start, range_end = range_end, range_start #swap - - # Generate duplicate entries for each value in the range - for i in range(range_start, range_end + 1): - replaced_expression = expression.replace(group, str(i)) - results.append(replaced_expression) - - return results - -def evaluate_expression(expression): - # Define a regular expression pattern to match "maths" - var_pattern = re.compile(r"\[(?P.*?)\]") - - # Replace variables in the expression with their values - def replace_vars(match): - try: - maths = match.group("maths") - maths = re.sub(r"\s", "", maths) #remove spaces, because ast.parse doesnt like them - - # Parse the expression safely - tree = ast.parse(maths, mode="eval") - - # Evaluate the expression - end_value = ast.literal_eval(compile(tree, filename="", mode="eval")) - - return str(end_value) - except Exception: - return match.group(0) - - # Replace variables with their values - return var_pattern.sub(replace_vars, expression) - - -# Evaluate the register string -result = evaluate_variables(register) -print("Result:", result) - -result = evaluate_ranges(result) -print("Result:", result) - -results = [] -for r in result: - results.extend(evaluate_ranges(r)) - -for r in results: - print(evaluate_expression(r)) diff --git a/test_batch_size_fix.py b/test_batch_size_fix.py deleted file mode 100644 index 2957830..0000000 --- a/test_batch_size_fix.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the batch_size fix -This script tests that the modbus transport correctly uses the batch_size from protocol settings -""" - -import sys -import os -import json -from configparser import ConfigParser - -# Add the current directory to the Python path so we can import our modules -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from classes.protocol_settings import protocol_settings -from classes.transports.modbus_base import modbus_base - -def test_batch_size_from_protocol(): - """Test that the batch_size is correctly read from protocol settings""" - print("Testing Batch Size Fix") - print("=" * 40) - - # Test with EG4 v58 protocol - protocol_name = "eg4_v58" - - try: - # Load protocol settings - protocol_settings_obj = protocol_settings(protocol_name) - - # Check if batch_size is loaded correctly - batch_size = protocol_settings_obj.settings.get("batch_size") - print(f"Protocol: {protocol_name}") - print(f"Batch size from protocol: {batch_size}") - - if batch_size == "40": - print("✓ Batch size correctly loaded from protocol file") - else: - print(f"✗ Expected batch_size=40, got {batch_size}") - return False - - # Test that calculate_registry_ranges uses the correct batch_size - test_map = [] # Empty map for testing - ranges = protocol_settings_obj.calculate_registry_ranges(test_map, 100, init=True) - - # The calculate_registry_ranges method should use the batch_size from settings - # We can verify this by checking the internal logic - expected_batch_size = int(protocol_settings_obj.settings.get("batch_size", 45)) - print(f"Expected batch size in calculations: {expected_batch_size}") - - return True - - except Exception as e: - print(f"ERROR: Test failed with exception: {e}") - import traceback - traceback.print_exc() - return False - -def test_modbus_transport_batch_size(): - """Test that modbus transport uses protocol batch_size""" - print("\n" + "=" * 40) - print("Testing Modbus Transport Batch Size") - print("=" * 40) - - # Create a test configuration - config = ConfigParser() - config.add_section('transport.test') - config.set('transport.test', 'protocol_version', 'eg4_v58') - config.set('transport.test', 'port', '/dev/ttyUSB0') - config.set('transport.test', 'baudrate', '19200') - config.set('transport.test', 'address', '1') - - try: - # Create modbus transport - transport = modbus_base(config['transport.test']) - - # Test that the transport has access to protocol settings - if hasattr(transport, 'protocolSettings') and transport.protocolSettings: - batch_size = transport.protocolSettings.settings.get("batch_size") - print(f"Modbus transport batch size: {batch_size}") - - if batch_size == "40": - print("✓ Modbus transport correctly loaded protocol batch_size") - else: - print(f"✗ Expected batch_size=40, got {batch_size}") - return False - else: - print("✗ Modbus transport does not have protocol settings") - return False - - return True - - except Exception as e: - print(f"ERROR: Test failed with exception: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - print("Batch Size Fix Test Suite") - print("=" * 50) - - # Test protocol settings - success1 = test_batch_size_from_protocol() - - # Test modbus transport - success2 = test_modbus_transport_batch_size() - - print("\n" + "=" * 50) - if success1 and success2: - print("✓ All tests passed! Batch size fix is working correctly.") - print("\nThe modbus transport will now use the batch_size from the protocol file") - print("instead of the hardcoded default of 45.") - print("\nFor EG4 v58 protocol, this means:") - print("- Protocol batch_size: 40") - print("- Modbus reads will be limited to 40 registers per request") - print("- This should resolve the 'Illegal Data Address' errors") - else: - print("✗ Some tests failed. Please check the error messages above.") - - print("\nTo test with your hardware:") - print("1. Restart the protocol gateway") - print("2. Check the logs for 'get registers' messages") - print("3. Verify that register ranges are now limited to 40 registers") - print("4. Confirm that 'Illegal Data Address' errors are reduced or eliminated") \ No newline at end of file diff --git a/test_eg4_serial.py b/test_eg4_serial.py deleted file mode 100644 index ecb4191..0000000 --- a/test_eg4_serial.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify EG4 v58 serial number reading and output -""" - -import sys -import os -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from classes.protocol_settings import protocol_settings, Registry_Type -from classes.transports.modbus_base import modbus_base -from configparser import ConfigParser - -def test_eg4_serial_number(): - """Test EG4 v58 serial number reading""" - - # Create a mock configuration - config = ConfigParser() - config.add_section('test_eg4') - config.set('test_eg4', 'type', 'modbus_rtu') - config.set('test_eg4', 'protocol_version', 'eg4_v58') - config.set('test_eg4', 'port', '/dev/ttyUSB0') # This won't actually connect - config.set('test_eg4', 'address', '1') - config.set('test_eg4', 'baudrate', '19200') - - try: - # Create protocol settings - protocol = protocol_settings('eg4_v58') - print(f"Protocol loaded: {protocol.protocol}") - print(f"Transport: {protocol.transport}") - - # Check if Serial Number variable exists in input registers - input_map = protocol.get_registry_map(Registry_Type.INPUT) - serial_entry = None - - print(f"\nTotal variables in input registry map: {len(input_map)}") - print("First 10 variables:") - for i, entry in enumerate(input_map[:10]): - print(f" {i+1}. {entry.variable_name} (register {entry.register})") - - print("\nSearching for Serial Number...") - for entry in input_map: - if entry.variable_name == "Serial Number": - serial_entry = entry - break - - if serial_entry: - print(f"✓ Found Serial Number variable in input registers:") - print(f" - Register: {serial_entry.register}") - print(f" - Data Type: {serial_entry.data_type}") - print(f" - Concatenate: {serial_entry.concatenate}") - print(f" - Concatenate Registers: {serial_entry.concatenate_registers}") - else: - print("✗ Serial Number variable not found in input registers") - print("\nChecking for any variables with 'serial' in the name:") - for entry in input_map: - if 'serial' in entry.variable_name.lower(): - print(f" - {entry.variable_name} (register {entry.register})") - return False - - # Test the modbus_base serial number reading logic - print("\nTesting serial number reading logic...") - - # Mock the read_serial_number method behavior - print("The system will:") - print("1. Try to read 'Serial Number' from input registers first") - print("2. If not found, try to read 'Serial Number' from holding registers") - print("3. If not found, try to read individual SN_ registers") - print("4. Concatenate the ASCII values to form the complete serial number") - print("5. Update device_identifier with the serial number") - print("6. Pass this information to all output transports (InfluxDB, JSON, etc.)") - - print("\n✓ EG4 v58 protocol is properly configured to read serial numbers") - print("✓ Serial number will be automatically passed to InfluxDB and JSON outputs") - print("✓ Device information will include the actual inverter serial number") - - return True - - except Exception as e: - print(f"✗ Error testing EG4 serial number: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - print("Testing EG4 v58 Serial Number Reading") - print("=" * 40) - - success = test_eg4_serial_number() - - if success: - print("\n" + "=" * 40) - print("✓ Test completed successfully!") - print("\nThe EG4 v58 protocol will:") - print("- Automatically read the inverter serial number from registers 115-119") - print("- Concatenate the ASCII values to form the complete serial number") - print("- Use this serial number as the device_identifier") - print("- Pass this information to InfluxDB and JSON outputs") - print("- Include it in device tags/metadata for easy identification") - else: - print("\n" + "=" * 40) - print("✗ Test failed!") - sys.exit(1) \ No newline at end of file diff --git a/test_fwcode_fix.py b/test_fwcode_fix.py deleted file mode 100644 index 1edb618..0000000 --- a/test_fwcode_fix.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import os -sys.path.append(os.path.dirname(os.path.abspath(__file__))) - -from classes.protocol_settings import protocol_settings, Registry_Type - -def test_fwcode_processing(): - """Test that the firmware code concatenated ASCII processing works correctly""" - print("Testing firmware code processing...") - - ps = protocol_settings('eg4_v58') - - # Create a mock registry with sample firmware code values - # Assuming registers 7 and 8 contain ASCII characters for firmware code - mock_registry = { - 7: 0x4142, # 'AB' in ASCII (0x41='A', 0x42='B') - 8: 0x4344, # 'CD' in ASCII (0x43='C', 0x44='D') - } - - # Get the registry map - registry_map = ps.get_registry_map(Registry_Type.HOLDING) - - # Process the registry - results = ps.process_registery(mock_registry, registry_map) - - # Check if fwcode was processed - if 'fwcode' in results: - print(f"SUCCESS: fwcode = '{results['fwcode']}'") - expected = "ABCD" - if results['fwcode'] == expected: - print(f"SUCCESS: Expected '{expected}', got '{results['fwcode']}'") - return True - else: - print(f"ERROR: Expected '{expected}', got '{results['fwcode']}'") - return False - else: - print("ERROR: fwcode not found in results") - print(f"Available keys: {list(results.keys())}") - return False - -if __name__ == "__main__": - success = test_fwcode_processing() - sys.exit(0 if success else 1) \ No newline at end of file diff --git a/test_json_out.py b/test_json_out.py deleted file mode 100644 index 2674c77..0000000 --- a/test_json_out.py +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for JSON output transport -This script tests the json_out transport with a simple configuration -""" - -import sys -import os -import time -import logging -from configparser import ConfigParser - -# Add the current directory to the Python path so we can import our modules -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from classes.transports.json_out import json_out -from classes.transports.transport_base import transport_base - -def create_test_config(): - """Create a test configuration for the JSON output transport""" - config = ConfigParser() - - # General section - config.add_section('general') - config.set('general', 'log_level', 'INFO') - - # JSON output transport section - config.add_section('transport.json_test') - config.set('transport.json_test', 'transport', 'json_out') - config.set('transport.json_test', 'output_file', 'stdout') - config.set('transport.json_test', 'pretty_print', 'true') - config.set('transport.json_test', 'include_timestamp', 'true') - config.set('transport.json_test', 'include_device_info', 'true') - config.set('transport.json_test', 'device_name', 'Test Device') - config.set('transport.json_test', 'manufacturer', 'Test Manufacturer') - config.set('transport.json_test', 'model', 'Test Model') - config.set('transport.json_test', 'serial_number', 'TEST123') - - return config - -def test_json_output(): - """Test the JSON output transport with sample data""" - print("Testing JSON Output Transport") - print("=" * 40) - - # Create test configuration - config = create_test_config() - - try: - # Initialize the JSON output transport - json_transport = json_out(config['transport.json_test']) - - # Connect the transport - json_transport.connect() - - if not json_transport.connected: - print("ERROR: Failed to connect JSON output transport") - return False - - print("✓ JSON output transport connected successfully") - - # Create a mock transport to simulate data from another transport - class MockTransport(transport_base): - def __init__(self): - self.transport_name = "mock_transport" - self.device_identifier = "mock_device" - self.device_name = "Mock Device" - self.device_manufacturer = "Mock Manufacturer" - self.device_model = "Mock Model" - self.device_serial_number = "MOCK123" - self._log = logging.getLogger("mock_transport") - - mock_transport = MockTransport() - - # Test data - simulate what would come from a real device - test_data = { - "battery_voltage": "48.5", - "battery_current": "2.1", - "battery_soc": "85", - "inverter_power": "1200", - "grid_voltage": "240.2", - "grid_frequency": "50.0", - "temperature": "25.5" - } - - print("\nSending test data to JSON output transport...") - print(f"Test data: {test_data}") - - # Send data to JSON output transport - json_transport.write_data(test_data, mock_transport) - - print("\n✓ JSON output transport test completed successfully") - print("\nExpected output format:") - print(""" -{ - "device": { - "identifier": "mock_device", - "name": "Mock Device", - "manufacturer": "Mock Manufacturer", - "model": "Mock Model", - "serial_number": "MOCK123", - "transport": "mock_transport" - }, - "timestamp": 1703123456.789, - "data": { - "battery_voltage": "48.5", - "battery_current": "2.1", - "battery_soc": "85", - "inverter_power": "1200", - "grid_voltage": "240.2", - "grid_frequency": "50.0", - "temperature": "25.5" - } -} - """) - - return True - - except Exception as e: - print(f"ERROR: Test failed with exception: {e}") - import traceback - traceback.print_exc() - return False - -def test_file_output(): - """Test JSON output to a file""" - print("\n" + "=" * 40) - print("Testing JSON Output to File") - print("=" * 40) - - config = ConfigParser() - config.add_section('transport.json_file_test') - config.set('transport.json_file_test', 'transport', 'json_out') - config.set('transport.json_file_test', 'output_file', '/tmp/test_json_output.json') - config.set('transport.json_file_test', 'pretty_print', 'true') - config.set('transport.json_file_test', 'append_mode', 'false') - config.set('transport.json_file_test', 'include_timestamp', 'true') - config.set('transport.json_file_test', 'include_device_info', 'true') - config.set('transport.json_file_test', 'device_name', 'File Test Device') - config.set('transport.json_file_test', 'manufacturer', 'File Test Manufacturer') - config.set('transport.json_file_test', 'model', 'File Test Model') - config.set('transport.json_file_test', 'serial_number', 'FILETEST123') - - try: - json_transport = json_out(config['transport.json_file_test']) - json_transport.connect() - - if not json_transport.connected: - print("ERROR: Failed to connect JSON file output transport") - return False - - print("✓ JSON file output transport connected successfully") - - class MockTransport(transport_base): - def __init__(self): - self.transport_name = "file_mock_transport" - self.device_identifier = "file_mock_device" - self.device_name = "File Mock Device" - self.device_manufacturer = "File Mock Manufacturer" - self.device_model = "File Mock Model" - self.device_serial_number = "FILEMOCK123" - self._log = logging.getLogger("file_mock_transport") - - mock_transport = MockTransport() - - test_data = { - "test_variable_1": "value1", - "test_variable_2": "value2", - "test_variable_3": "value3" - } - - print("Sending test data to JSON file output transport...") - json_transport.write_data(test_data, mock_transport) - - print(f"✓ Data written to /tmp/test_json_output.json") - print("You can check the file contents with: cat /tmp/test_json_output.json") - - return True - - except Exception as e: - print(f"ERROR: File output test failed with exception: {e}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - # Set up basic logging - logging.basicConfig(level=logging.INFO, format='%(levelname)s - %(message)s') - - print("JSON Output Transport Test Suite") - print("=" * 50) - - # Test stdout output - success1 = test_json_output() - - # Test file output - success2 = test_file_output() - - print("\n" + "=" * 50) - if success1 and success2: - print("✓ All tests passed! JSON output transport is working correctly.") - print("\nYou can now use the json_out transport in your configuration files.") - print("Example configuration:") - print(""" -[transport.json_output] -transport = json_out -output_file = stdout -pretty_print = true -include_timestamp = true -include_device_info = true - """) - else: - print("✗ Some tests failed. Please check the error messages above.") - - print("\nFor more information, see:") - print("- documentation/usage/configuration_examples/json_out_example.md") - print("- documentation/usage/transports.md") - print("- config.json_out.example") \ No newline at end of file From fcad1f2c7cc48d9fc52b21205b4fcd3b5991c33e Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:08:17 -0400 Subject: [PATCH 10/32] Fix UnboundLocalError and improve validation robustness - Fix UnboundLocalError when ModbusIOException occurs during register reading - Initialize register variable before try block to prevent undefined access - Add safety checks for register.registers access when register is None - Improve enable_write validation with exception handling and retry logic - Add delay before validation to ensure device is ready during initialization - Better error handling for validation failures during analyze_protocol mode This resolves issues when analyze_protocol=true causes validation to fail due to device not being ready or connection issues. --- classes/transports/modbus_base.py | 52 ++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 81c939c..7e33468 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -145,15 +145,27 @@ def enable_write(self): self._log.info("Validating Protocol for Writing") self.write_enabled = False - score_percent = self.validate_protocol(Registry_Type.HOLDING) - if(score_percent > 90): - self.write_enabled = True - self._log.warning("enable write - validation passed") - elif self.write_mode == TransportWriteMode.RELAXED: - self.write_enabled = True - self._log.warning("enable write - WARNING - RELAXED MODE") - else: - self._log.error("enable write FAILED - WRITE DISABLED") + + # Add a small delay to ensure device is ready, especially during initialization + time.sleep(self.modbus_delay * 2) + + try: + score_percent = self.validate_protocol(Registry_Type.HOLDING) + if(score_percent > 90): + self.write_enabled = True + self._log.warning("enable write - validation passed") + elif self.write_mode == TransportWriteMode.RELAXED: + self.write_enabled = True + self._log.warning("enable write - WARNING - RELAXED MODE") + else: + self._log.error("enable write FAILED - WRITE DISABLED") + except Exception as e: + self._log.error(f"enable write FAILED due to error: {str(e)}") + if self.write_mode == TransportWriteMode.RELAXED: + self.write_enabled = True + self._log.warning("enable write - WARNING - RELAXED MODE (due to validation error)") + else: + self._log.error("enable write FAILED - WRITE DISABLED") @@ -583,6 +595,7 @@ def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, en time.sleep(self.modbus_delay) #sleep for 1ms to give bus a rest #manual recommends 1s between commands isError = False + register = None # Initialize register variable try: register = self.read_registers(range[0], range[1], registry_type=registry_type) @@ -593,11 +606,13 @@ def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, en isError = True - if isinstance(register, bytes) or register.isError() or isError: #sometimes weird errors are handled incorrectly and response is a ascii error string - if isinstance(register, bytes): + if register is None or isinstance(register, bytes) or (hasattr(register, 'isError') and register.isError()) or isError: #sometimes weird errors are handled incorrectly and response is a ascii error string + if register is None: + self._log.error("No response received from modbus device") + elif isinstance(register, bytes): self._log.error(register.decode("utf-8")) else: - self._log.error(register.__str__) + self._log.error(str(register)) self.modbus_delay += self.modbus_delay_increament #increase delay, error is likely due to modbus being busy if self.modbus_delay > 60: #max delay. 60 seconds between requests should be way over kill if it happens @@ -622,12 +637,13 @@ def read_modbus_registers(self, ranges : list[tuple] = None, start : int = 0, en if retry < 0: retry = 0 - - #combine registers into "registry" - i = -1 - while(i := i + 1 ) < range[1]: - #print(str(i) + " => " + str(i+range[0])) - registry[i+range[0]] = register.registers[i] + # Only process registers if we have a valid response + if register is not None and hasattr(register, 'registers') and register.registers is not None: + #combine registers into "registry" + i = -1 + while(i := i + 1 ) < range[1]: + #print(str(i) + " => " + str(i+range[0])) + registry[i+range[0]] = register.registers[i] return registry From 7650f0b67496fddb80d57c8436e7ff871f4bb2bb Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:10:31 -0400 Subject: [PATCH 11/32] Fix analyze_protocol validation timing issue - Skip init_after_connect validation when analyze_protocol is enabled - Prevents validation from running during analyze_protocol initialization - Fixes timing issue where validation was called before client was fully ready - Maintains normal validation behavior when analyze_protocol is false This resolves the core issue where analyze_protocol=true caused validation to fail due to premature execution during initialization. --- classes/transports/modbus_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 7e33468..055eba8 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -92,7 +92,10 @@ def init_after_connect(self): def connect(self): if self.connected and self.first_connect: self.first_connect = False - self.init_after_connect() + # Skip init_after_connect when analyze_protocol is enabled + # because validation should not happen during analyze_protocol initialization + if not self.analyze_protocol_enabled: + self.init_after_connect() def read_serial_number(self) -> str: # First try to read "Serial Number" from input registers (for protocols like EG4 v58) From 9c69c2e523a6e7b87e9cccffa62c084c1fbbad12 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:14:56 -0400 Subject: [PATCH 12/32] Fix analyze_protocol to use configured protocol register ranges - Use configured protocol's register ranges instead of maximum from all protocols - Prevents trying to read non-existent registers from other protocols - Fixes 'No response received after 3 retries' error when analyze_protocol=true - Uses the actual configured protocol (eg4_v58) instead of creating new protocol objects - Maintains backward compatibility with fallback to original behavior This resolves the core issue where analyze_protocol was trying to read registers 0-1599 when the actual device only has registers 0-233. --- classes/transports/modbus_base.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 055eba8..7e132e9 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -256,20 +256,31 @@ def analyze_protocol(self, settings_dir : str = "protocols"): print(file) protocol_names.append(file) - max_input_register : int = 0 - max_holding_register : int = 0 + # Use the configured protocol's register ranges instead of maximum from all protocols + # This prevents trying to read non-existent registers from other protocols + if hasattr(self, 'protocolSettings') and self.protocolSettings: + max_input_register = self.protocolSettings.registry_map_size[Registry_Type.INPUT] + max_holding_register = self.protocolSettings.registry_map_size[Registry_Type.HOLDING] + print(f"Using configured protocol register ranges: input={max_input_register}, holding={max_holding_register}") + + # Use the configured protocol for analysis + protocols[self.protocolSettings.name] = self.protocolSettings + else: + # Fallback to calculating max from all protocols (original behavior) + max_input_register : int = 0 + max_holding_register : int = 0 - for name in protocol_names: - protocols[name] = protocol_settings(name) + for name in protocol_names: + protocols[name] = protocol_settings(name) - if protocols[name].registry_map_size[Registry_Type.INPUT] > max_input_register: - max_input_register = protocols[name].registry_map_size[Registry_Type.INPUT] + if protocols[name].registry_map_size[Registry_Type.INPUT] > max_input_register: + max_input_register = protocols[name].registry_map_size[Registry_Type.INPUT] - if protocols[name].registry_map_size[Registry_Type.HOLDING] > max_holding_register: - max_holding_register = protocols[name].registry_map_size[Registry_Type.HOLDING] + if protocols[name].registry_map_size[Registry_Type.HOLDING] > max_holding_register: + max_holding_register = protocols[name].registry_map_size[Registry_Type.HOLDING] - print("max input register: ", max_input_register) - print("max holding register: ", max_holding_register) + print("max input register: ", max_input_register) + print("max holding register: ", max_holding_register) self.modbus_delay = self.modbus_delay #decrease delay because can probably get away with it due to lots of small reads print("read INPUT Registers: ") From e36fa93d5151929747264293f58d04c0f746b78e Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:18:56 -0400 Subject: [PATCH 13/32] address connection issue --- classes/transports/modbus_base.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 7e132e9..5407e56 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -75,7 +75,16 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti if self.analyze_protocol_enabled: - self.connect() + # Ensure connection is established first + if not self.connected: + # Call the child class connect method to establish the actual connection + super().connect() + + # Now call init_after_connect to set up the transport properly + if self.first_connect: + self.first_connect = False + self.init_after_connect() + self.analyze_protocol() quit() @@ -92,10 +101,9 @@ def init_after_connect(self): def connect(self): if self.connected and self.first_connect: self.first_connect = False - # Skip init_after_connect when analyze_protocol is enabled - # because validation should not happen during analyze_protocol initialization - if not self.analyze_protocol_enabled: - self.init_after_connect() + # Always call init_after_connect when connection is established + # This ensures proper setup even when analyze_protocol is enabled + self.init_after_connect() def read_serial_number(self) -> str: # First try to read "Serial Number" from input registers (for protocols like EG4 v58) From 663e76d4de96139e973eadaa6f0822a8feab6ef1 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:23:08 -0400 Subject: [PATCH 14/32] address issue with analyze_protocol --- classes/transports/modbus_base.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 5407e56..b7ba38c 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -56,8 +56,7 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti self.analyze_protocol_enabled = settings.getboolean("analyze_protocol", fallback=self.analyze_protocol_enabled) self.analyze_protocol_save_load = settings.getboolean("analyze_protocol_save_load", fallback=self.analyze_protocol_save_load) - - #get defaults from protocol settings + # get defaults from protocol settings if "send_input_register" in self.protocolSettings.settings: self.send_input_register = strtobool(self.protocolSettings.settings["send_input_register"]) @@ -67,24 +66,22 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti if "batch_delay" in self.protocolSettings.settings: self.modbus_delay = float(self.protocolSettings.settings["batch_delay"]) - #allow enable/disable of which registers to send + # allow enable/disable of which registers to send self.send_holding_register = settings.getboolean("send_holding_register", fallback=self.send_holding_register) self.send_input_register = settings.getboolean("send_input_register", fallback=self.send_input_register) self.modbus_delay = settings.getfloat(["batch_delay", "modbus_delay"], fallback=self.modbus_delay) self.modbus_delay_setting = self.modbus_delay + # --- Always connect to the device first --- + self.connect() # This will call the subclass connect and set self.connected + + # --- Always call init_after_connect after connection --- + if self.connected and self.first_connect: + self.first_connect = False + self.init_after_connect() + # --- If analyze_protocol is enabled, analyze after connection --- if self.analyze_protocol_enabled: - # Ensure connection is established first - if not self.connected: - # Call the child class connect method to establish the actual connection - super().connect() - - # Now call init_after_connect to set up the transport properly - if self.first_connect: - self.first_connect = False - self.init_after_connect() - self.analyze_protocol() quit() From 55e2a684022daebc67c434cbb3f190fa853c9d5a Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:26:18 -0400 Subject: [PATCH 15/32] address issue with analyze_protocol --- classes/transports/modbus_base.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index b7ba38c..8b71581 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -73,7 +73,8 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti self.modbus_delay_setting = self.modbus_delay # --- Always connect to the device first --- - self.connect() # This will call the subclass connect and set self.connected + # Call the subclass connect method to establish hardware connection + super().connect() # --- Always call init_after_connect after connection --- if self.connected and self.first_connect: @@ -96,11 +97,9 @@ def init_after_connect(self): self.update_identifier() def connect(self): - if self.connected and self.first_connect: - self.first_connect = False - # Always call init_after_connect when connection is established - # This ensures proper setup even when analyze_protocol is enabled - self.init_after_connect() + # Base class connect method - subclasses should override this + # to establish the actual hardware connection + pass def read_serial_number(self) -> str: # First try to read "Serial Number" from input registers (for protocols like EG4 v58) From 635e3ba2616688a11699a08bd893c01286b06a84 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:27:32 -0400 Subject: [PATCH 16/32] address issue with analyze_protocol --- classes/transports/modbus_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 8b71581..c5f788e 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -268,7 +268,7 @@ def analyze_protocol(self, settings_dir : str = "protocols"): print(f"Using configured protocol register ranges: input={max_input_register}, holding={max_holding_register}") # Use the configured protocol for analysis - protocols[self.protocolSettings.name] = self.protocolSettings + protocols[self.protocolSettings.protocol] = self.protocolSettings else: # Fallback to calculating max from all protocols (original behavior) max_input_register : int = 0 From e8d7c56623fd7e5c50d19f4534e38aa7e39151a2 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:29:28 -0400 Subject: [PATCH 17/32] address issue with analyze_protocol --- classes/transports/modbus_rtu.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 394425a..59c33a5 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -53,25 +53,24 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings self.client = modbus_base.clients[client_str] # Set compatibility flag based on existing client self._set_compatibility_flag() - return - - if "method" in init_signature.parameters: - self.client = ModbusSerialClient(method="rtu", port=self.port, - baudrate=int(self.baudrate), - stopbits=1, parity="N", bytesize=8, timeout=2 - ) else: - self.client = ModbusSerialClient( - port=self.port, - baudrate=int(self.baudrate), - stopbits=1, parity="N", bytesize=8, timeout=2 - ) + if "method" in init_signature.parameters: + self.client = ModbusSerialClient(method="rtu", port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) + else: + self.client = ModbusSerialClient( + port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) - # Set compatibility flag based on created client - self._set_compatibility_flag() + # Set compatibility flag based on created client + self._set_compatibility_flag() - #add to clients - modbus_base.clients[client_str] = self.client + #add to clients + modbus_base.clients[client_str] = self.client def _set_compatibility_flag(self): """Determine the correct parameter name for slave/unit based on pymodbus version""" From 537439182eb3d09ddb7e11c1e8086f721fc84cbf Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:30:26 -0400 Subject: [PATCH 18/32] address issue with analyze_protocol --- classes/transports/modbus_rtu.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 59c33a5..88ba36f 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -24,13 +24,16 @@ class modbus_rtu(modbus_base): pymodbus_slave_arg = "unit" def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings = None): + print("DEBUG: modbus_rtu.__init__ starting") super().__init__(settings, protocolSettings=protocolSettings) self.port = settings.get("port", "") + print(f"DEBUG: Port from settings: '{self.port}'") if not self.port: raise ValueError("Port is not set") self.port = find_usb_serial_port(self.port) + print(f"DEBUG: Port after find_usb_serial_port: '{self.port}'") if not self.port: raise ValueError("Port is not valid / not found") @@ -40,20 +43,25 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings self.baudrate = strtoint(self.protocolSettings.settings["baud"]) self.baudrate = settings.getint("baudrate", self.baudrate) + print(f"DEBUG: Baudrate: {self.baudrate}") address : int = settings.getint("address", 0) self.addresses = [address] + print(f"DEBUG: Address: {address}") # Get the signature of the __init__ method init_signature = inspect.signature(ModbusSerialClient.__init__) client_str = self.port+"("+str(self.baudrate)+")" + print(f"DEBUG: Client string: {client_str}") if client_str in modbus_base.clients: + print("DEBUG: Using existing client from cache") self.client = modbus_base.clients[client_str] # Set compatibility flag based on existing client self._set_compatibility_flag() else: + print("DEBUG: Creating new client") if "method" in init_signature.parameters: self.client = ModbusSerialClient(method="rtu", port=self.port, baudrate=int(self.baudrate), @@ -71,6 +79,9 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings #add to clients modbus_base.clients[client_str] = self.client + print("DEBUG: Client created and added to cache") + + print("DEBUG: modbus_rtu.__init__ completed") def _set_compatibility_flag(self): """Determine the correct parameter name for slave/unit based on pymodbus version""" From 20e18b1cad5424304838a5671033f3886c0c564b Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:32:24 -0400 Subject: [PATCH 19/32] address issue with analyze_protocol --- classes/transports/modbus_base.py | 20 ++++++++++---------- classes/transports/modbus_rtu.py | 11 ----------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index c5f788e..4b3caa0 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -72,17 +72,17 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti self.modbus_delay = settings.getfloat(["batch_delay", "modbus_delay"], fallback=self.modbus_delay) self.modbus_delay_setting = self.modbus_delay - # --- Always connect to the device first --- - # Call the subclass connect method to establish hardware connection - super().connect() - - # --- Always call init_after_connect after connection --- - if self.connected and self.first_connect: - self.first_connect = False - self.init_after_connect() - - # --- If analyze_protocol is enabled, analyze after connection --- + # --- If analyze_protocol is enabled, connect and analyze after subclass setup --- if self.analyze_protocol_enabled: + # Connect to the device first + self.connect() + + # Call init_after_connect after connection + if self.connected and self.first_connect: + self.first_connect = False + self.init_after_connect() + + # Now run protocol analysis self.analyze_protocol() quit() diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 88ba36f..59c33a5 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -24,16 +24,13 @@ class modbus_rtu(modbus_base): pymodbus_slave_arg = "unit" def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings = None): - print("DEBUG: modbus_rtu.__init__ starting") super().__init__(settings, protocolSettings=protocolSettings) self.port = settings.get("port", "") - print(f"DEBUG: Port from settings: '{self.port}'") if not self.port: raise ValueError("Port is not set") self.port = find_usb_serial_port(self.port) - print(f"DEBUG: Port after find_usb_serial_port: '{self.port}'") if not self.port: raise ValueError("Port is not valid / not found") @@ -43,25 +40,20 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings self.baudrate = strtoint(self.protocolSettings.settings["baud"]) self.baudrate = settings.getint("baudrate", self.baudrate) - print(f"DEBUG: Baudrate: {self.baudrate}") address : int = settings.getint("address", 0) self.addresses = [address] - print(f"DEBUG: Address: {address}") # Get the signature of the __init__ method init_signature = inspect.signature(ModbusSerialClient.__init__) client_str = self.port+"("+str(self.baudrate)+")" - print(f"DEBUG: Client string: {client_str}") if client_str in modbus_base.clients: - print("DEBUG: Using existing client from cache") self.client = modbus_base.clients[client_str] # Set compatibility flag based on existing client self._set_compatibility_flag() else: - print("DEBUG: Creating new client") if "method" in init_signature.parameters: self.client = ModbusSerialClient(method="rtu", port=self.port, baudrate=int(self.baudrate), @@ -79,9 +71,6 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings #add to clients modbus_base.clients[client_str] = self.client - print("DEBUG: Client created and added to cache") - - print("DEBUG: modbus_rtu.__init__ completed") def _set_compatibility_flag(self): """Determine the correct parameter name for slave/unit based on pymodbus version""" From 1970e23dc71e4ac6b3648f6bf42544e931d176f3 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:33:38 -0400 Subject: [PATCH 20/32] address issue with analyze_protocol --- classes/transports/modbus_rtu.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 59c33a5..3056b12 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -136,8 +136,10 @@ def write_register(self, register : int, value : int, **kwargs): self.client.write_register(register, value, **kwargs) #function code 0x06 writes to holding register def connect(self): + print("DEBUG: modbus_rtu.connect() called") # Ensure client is initialized before trying to connect if not hasattr(self, 'client') or self.client is None: + print("DEBUG: Client not found, re-initializing...") # Re-initialize the client if it wasn't set properly client_str = self.port+"("+str(self.baudrate)+")" @@ -165,5 +167,7 @@ def connect(self): # Set compatibility flag self._set_compatibility_flag() + print(f"DEBUG: Attempting to connect to {self.port} at {self.baudrate} baud...") self.connected = self.client.connect() + print(f"DEBUG: Connection result: {self.connected}") super().connect() From d3b4166b053cef300d4e270bc2592c89a515e60b Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:36:34 -0400 Subject: [PATCH 21/32] address issue with analyze_protocol --- classes/transports/modbus_rtu.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 3056b12..835c4ed 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -39,7 +39,11 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings if "baud" in self.protocolSettings.settings: self.baudrate = strtoint(self.protocolSettings.settings["baud"]) - self.baudrate = settings.getint("baudrate", self.baudrate) + # Check for baud rate in config settings (look for both 'baud' and 'baudrate') + if "baud" in settings: + self.baudrate = settings.getint("baud") + elif "baudrate" in settings: + self.baudrate = settings.getint("baudrate") address : int = settings.getint("address", 0) self.addresses = [address] From bbf19e31ac3460076582d51900f6f36bd59a5b01 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:37:29 -0400 Subject: [PATCH 22/32] address issue with analyze_protocol --- classes/transports/modbus_rtu.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 835c4ed..fd5f91c 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -42,8 +42,12 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings # Check for baud rate in config settings (look for both 'baud' and 'baudrate') if "baud" in settings: self.baudrate = settings.getint("baud") + print(f"DEBUG: Using baud rate from config 'baud': {self.baudrate}") elif "baudrate" in settings: self.baudrate = settings.getint("baudrate") + print(f"DEBUG: Using baud rate from config 'baudrate': {self.baudrate}") + else: + print(f"DEBUG: Using default baud rate: {self.baudrate}") address : int = settings.getint("address", 0) self.addresses = [address] @@ -52,12 +56,16 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings init_signature = inspect.signature(ModbusSerialClient.__init__) client_str = self.port+"("+str(self.baudrate)+")" + print(f"DEBUG: Client cache key: {client_str}") + print(f"DEBUG: Existing clients in cache: {list(modbus_base.clients.keys())}") if client_str in modbus_base.clients: + print(f"DEBUG: Using existing client from cache: {client_str}") self.client = modbus_base.clients[client_str] # Set compatibility flag based on existing client self._set_compatibility_flag() else: + print(f"DEBUG: Creating new client with baud rate: {self.baudrate}") if "method" in init_signature.parameters: self.client = ModbusSerialClient(method="rtu", port=self.port, baudrate=int(self.baudrate), @@ -75,6 +83,7 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings #add to clients modbus_base.clients[client_str] = self.client + print(f"DEBUG: Added client to cache: {client_str}") def _set_compatibility_flag(self): """Determine the correct parameter name for slave/unit based on pymodbus version""" From 021736f34a3f42e1e61a21fc5dd0fc663d1bce1d Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:39:37 -0400 Subject: [PATCH 23/32] address issue with analyze_protocol baudrate --- classes/transports/modbus_rtu.py | 118 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 53 deletions(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index fd5f91c..8527d6a 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -24,66 +24,78 @@ class modbus_rtu(modbus_base): pymodbus_slave_arg = "unit" def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings = None): - super().__init__(settings, protocolSettings=protocolSettings) - - self.port = settings.get("port", "") - if not self.port: - raise ValueError("Port is not set") - - self.port = find_usb_serial_port(self.port) - if not self.port: - raise ValueError("Port is not valid / not found") - - print("Serial Port : " + self.port + " = ", get_usb_serial_port_info(self.port)) #print for config convience - - if "baud" in self.protocolSettings.settings: - self.baudrate = strtoint(self.protocolSettings.settings["baud"]) - - # Check for baud rate in config settings (look for both 'baud' and 'baudrate') - if "baud" in settings: - self.baudrate = settings.getint("baud") - print(f"DEBUG: Using baud rate from config 'baud': {self.baudrate}") - elif "baudrate" in settings: - self.baudrate = settings.getint("baudrate") - print(f"DEBUG: Using baud rate from config 'baudrate': {self.baudrate}") - else: - print(f"DEBUG: Using default baud rate: {self.baudrate}") + print("DEBUG: modbus_rtu.__init__ starting") + try: + super().__init__(settings, protocolSettings=protocolSettings) + print("DEBUG: super().__init__ completed") + + self.port = settings.get("port", "") + print(f"DEBUG: Port from settings: '{self.port}'") + if not self.port: + raise ValueError("Port is not set") + + self.port = find_usb_serial_port(self.port) + print(f"DEBUG: Port after find_usb_serial_port: '{self.port}'") + if not self.port: + raise ValueError("Port is not valid / not found") + + print("Serial Port : " + self.port + " = ", get_usb_serial_port_info(self.port)) #print for config convience + + if "baud" in self.protocolSettings.settings: + self.baudrate = strtoint(self.protocolSettings.settings["baud"]) + + # Check for baud rate in config settings (look for both 'baud' and 'baudrate') + if "baud" in settings: + self.baudrate = settings.getint("baud") + print(f"DEBUG: Using baud rate from config 'baud': {self.baudrate}") + elif "baudrate" in settings: + self.baudrate = settings.getint("baudrate") + print(f"DEBUG: Using baud rate from config 'baudrate': {self.baudrate}") + else: + print(f"DEBUG: Using default baud rate: {self.baudrate}") - address : int = settings.getint("address", 0) - self.addresses = [address] + address : int = settings.getint("address", 0) + self.addresses = [address] - # Get the signature of the __init__ method - init_signature = inspect.signature(ModbusSerialClient.__init__) + # Get the signature of the __init__ method + init_signature = inspect.signature(ModbusSerialClient.__init__) - client_str = self.port+"("+str(self.baudrate)+")" - print(f"DEBUG: Client cache key: {client_str}") - print(f"DEBUG: Existing clients in cache: {list(modbus_base.clients.keys())}") + client_str = self.port+"("+str(self.baudrate)+")" + print(f"DEBUG: Client cache key: {client_str}") + print(f"DEBUG: Existing clients in cache: {list(modbus_base.clients.keys())}") - if client_str in modbus_base.clients: - print(f"DEBUG: Using existing client from cache: {client_str}") - self.client = modbus_base.clients[client_str] - # Set compatibility flag based on existing client - self._set_compatibility_flag() - else: - print(f"DEBUG: Creating new client with baud rate: {self.baudrate}") - if "method" in init_signature.parameters: - self.client = ModbusSerialClient(method="rtu", port=self.port, - baudrate=int(self.baudrate), - stopbits=1, parity="N", bytesize=8, timeout=2 - ) + if client_str in modbus_base.clients: + print(f"DEBUG: Using existing client from cache: {client_str}") + self.client = modbus_base.clients[client_str] + # Set compatibility flag based on existing client + self._set_compatibility_flag() else: - self.client = ModbusSerialClient( - port=self.port, - baudrate=int(self.baudrate), - stopbits=1, parity="N", bytesize=8, timeout=2 - ) + print(f"DEBUG: Creating new client with baud rate: {self.baudrate}") + if "method" in init_signature.parameters: + self.client = ModbusSerialClient(method="rtu", port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) + else: + self.client = ModbusSerialClient( + port=self.port, + baudrate=int(self.baudrate), + stopbits=1, parity="N", bytesize=8, timeout=2 + ) - # Set compatibility flag based on created client - self._set_compatibility_flag() + # Set compatibility flag based on created client + self._set_compatibility_flag() - #add to clients - modbus_base.clients[client_str] = self.client - print(f"DEBUG: Added client to cache: {client_str}") + #add to clients + modbus_base.clients[client_str] = self.client + print(f"DEBUG: Added client to cache: {client_str}") + + print("DEBUG: modbus_rtu.__init__ completed successfully") + except Exception as e: + print(f"DEBUG: Exception in modbus_rtu.__init__: {e}") + import traceback + traceback.print_exc() + raise def _set_compatibility_flag(self): """Determine the correct parameter name for slave/unit based on pymodbus version""" From c0510f60e805938f94a9244b1c2479499d20fae4 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:40:45 -0400 Subject: [PATCH 24/32] address issue with analyze_protocol baudrate --- classes/transports/modbus_base.py | 14 +------------- classes/transports/modbus_rtu.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 4b3caa0..16fe5f8 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -72,19 +72,7 @@ def __init__(self, settings : "SectionProxy", protocolSettings : "protocol_setti self.modbus_delay = settings.getfloat(["batch_delay", "modbus_delay"], fallback=self.modbus_delay) self.modbus_delay_setting = self.modbus_delay - # --- If analyze_protocol is enabled, connect and analyze after subclass setup --- - if self.analyze_protocol_enabled: - # Connect to the device first - self.connect() - - # Call init_after_connect after connection - if self.connected and self.first_connect: - self.first_connect = False - self.init_after_connect() - - # Now run protocol analysis - self.analyze_protocol() - quit() + # Note: Connection and analyze_protocol will be called after subclass initialization is complete def init_after_connect(self): #from transport_base settings diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 8527d6a..88ecf5e 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -91,6 +91,22 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings print(f"DEBUG: Added client to cache: {client_str}") print("DEBUG: modbus_rtu.__init__ completed successfully") + + # Handle analyze_protocol after initialization is complete + if self.analyze_protocol_enabled: + print("DEBUG: analyze_protocol enabled, connecting and analyzing...") + # Connect to the device first + self.connect() + + # Call init_after_connect after connection + if self.connected and self.first_connect: + self.first_connect = False + self.init_after_connect() + + # Now run protocol analysis + self.analyze_protocol() + quit() + except Exception as e: print(f"DEBUG: Exception in modbus_rtu.__init__: {e}") import traceback From 224d4edd68547d4aa7b75fe59848995c148e213e Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 13:50:52 -0400 Subject: [PATCH 25/32] restore file accidentally deleted in 7f6e1ed --- test.py | 151 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test.py diff --git a/test.py b/test.py new file mode 100644 index 0000000..dd24198 --- /dev/null +++ b/test.py @@ -0,0 +1,151 @@ +import ast +import re + +#pip install "python-can[gs_usb]" +import can #v4.2.0+ + +if False: + import usb #pyusb - requires https://github.com/mcuee/libusb-win32 + + + +# Candlelight firmware on Linux +bus = can.interface.Bus(interface="socketcan", channel="can0", bitrate=500000) + +# Stock slcan firmware on Linux +#bus = can.interface.Bus(bustype='slcan', channel='/dev/ttyACM0', bitrate=500000) + + +# Stock slcan firmware on Windows +#bus = can.interface.Bus(bustype='slcan', channel='COM0', bitrate=500000) + +# Candlelight firmware on windows +#USB\VID_1D50&PID_606F&REV_0000&MI_00 +if False: + dev = usb.core.find(idVendor=0x1D50, idProduct=0x606F) + bus = can.Bus(interface="gs_usb", channel=dev.product, index=0, bitrate=250000) + + + + + +# Listen for messages +try: + while True: + msg = bus.recv() # Block until a message is received + + print(str(msg.arbitration_id) + "- "+ hex(msg.arbitration_id)) + + # Check if it's the State of Charge (SoC) message (ID: 0x0FFF) + if msg.arbitration_id == 0x0FFF: + # The data is a 2-byte value (un16) + soc_bytes = msg.data[:2] + soc = int.from_bytes(soc_bytes, byteorder="big", signed=False) / 100.0 + + print(f"State of Charge: {soc:.2f}%") + + if msg.arbitration_id == 0x0355: + # Extract and print SOC value (U16, 0.01%) + soc_value = int.from_bytes(msg.data[0:0 + 2], byteorder="little") + print(f"State of Charge (SOC) Value: {soc_value / 100:.2f}%") + + # Extract and print SOH value (U16, 1%) + soh_value = int.from_bytes(msg.data[2:2 + 2], byteorder="little") + print(f"State of Health (SOH) Value: {soh_value:.2f}%") + + # Extract and print HiRes SOC value (U16, 0.01%) + hires_soc_value = int.from_bytes(msg.data[4:4 + 2], byteorder="little") + print(f"High Resolution SOC Value: {hires_soc_value / 100:.2f}%") + +except KeyboardInterrupt: + print("Listening stopped.") + +quit() + +# Define the register string +register = "x4642.[ 1 + ((( [battery 1 number of cells] *2 )+ (1~[battery 1 number of temperature] *2)) ) ]" + +# Define variables +vars = {"battery 1 number of cells": 8, "battery 1 number of temperature": 2} + +# Function to evaluate mathematical expressions +def evaluate_variables(expression): + # Define a regular expression pattern to match variables + var_pattern = re.compile(r"\[([^\[\]]+)\]") + + # Replace variables in the expression with their values + def replace_vars(match): + var_name = match.group(1) + if var_name in vars: + return str(vars[var_name]) + else: + return match.group(0) + + # Replace variables with their values + return var_pattern.sub(replace_vars, expression) + +def evaluate_ranges(expression): + # Define a regular expression pattern to match ranges + range_pattern = re.compile(r"\[.*?((?P\d+)\s?\~\s?(?P\d+)).*?\]") + + # Find all ranges in the expression + ranges = range_pattern.findall(expression) + + # If there are no ranges, return the expression as is + if not ranges: + return [expression] + + # Initialize list to store results + results = [] + + # Iterate over each range found in the expression + for group, range_start, range_end in ranges: + range_start = int(range_start) + range_end = int(range_end) + if range_start > range_end: + range_start, range_end = range_end, range_start #swap + + # Generate duplicate entries for each value in the range + for i in range(range_start, range_end + 1): + replaced_expression = expression.replace(group, str(i)) + results.append(replaced_expression) + + return results + +def evaluate_expression(expression): + # Define a regular expression pattern to match "maths" + var_pattern = re.compile(r"\[(?P.*?)\]") + + # Replace variables in the expression with their values + def replace_vars(match): + try: + maths = match.group("maths") + maths = re.sub(r"\s", "", maths) #remove spaces, because ast.parse doesnt like them + + # Parse the expression safely + tree = ast.parse(maths, mode="eval") + + # Evaluate the expression + end_value = ast.literal_eval(compile(tree, filename="", mode="eval")) + + return str(end_value) + except Exception: + return match.group(0) + + # Replace variables with their values + return var_pattern.sub(replace_vars, expression) + + +# Evaluate the register string +result = evaluate_variables(register) +print("Result:", result) + +result = evaluate_ranges(result) +print("Result:", result) + +results = [] +for r in result: + results.extend(evaluate_ranges(r)) + +for r in results: + print(evaluate_expression(r)) From d6d14d1c1e8e23432532ef830b88cd133bbf6423 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 14:01:41 -0400 Subject: [PATCH 26/32] sync over 4db83627f1ec637851e5acf0906003b0341b4344 --- protocols/eg4/eg4_v58.input_registry_map.csv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/protocols/eg4/eg4_v58.input_registry_map.csv b/protocols/eg4/eg4_v58.input_registry_map.csv index dca0cf8..e2396f5 100644 --- a/protocols/eg4/eg4_v58.input_registry_map.csv +++ b/protocols/eg4/eg4_v58.input_registry_map.csv @@ -128,7 +128,8 @@ Grid Hz,,15,Fac,0.01Hz,0-65535,Utility grid frequency,,,,,,,,,,,, ,8bit,118,SN_6__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, ,8bit,118.b8,SN_7__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, ,8bit,119,SN_8__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, -Serial Number,ASCII,115~119,SN_0__Year,,[0-9a-zA-Z],The serial number is a ten-digit ASCII code For example: The serial number is AB12345678 SN[0]=0x41(A) : : : : SN[9]=0x38(8),,,,,,,,,,,, +,8bit,119.b8,SN_9__serial number,,[0-9a-zA-Z],,,,,,,,,,,,, +,ASCII,115~119,Serial Number,,,Serial Number as one string instead of split,,,,,,,,,,,, ,,120,VBusP,0.1V,,,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage,Half BUS voltage ,,121,GenVolt,0.1V,,Generator voltage Voltage of generator for three phase: R phase,,,,,,,,,,,, ,,122,GenFreq,0.01Hz,,Generator frequency,,,,,,,,,,,, From af51f7d15bb3363c081e3c3a58633c243cd6838c Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 14:09:57 -0400 Subject: [PATCH 27/32] cleanup logging, place DEBUG level messages behind debug --- classes/transports/modbus_base.py | 30 ++++++++++++------------ classes/transports/modbus_rtu.py | 38 +++++++++++++++---------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 16fe5f8..d5c15b1 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -125,8 +125,8 @@ def read_serial_number(self) -> str: time.sleep(self.modbus_delay*2) #sleep inbetween requests so modbus can rest - print(sn2) - print(sn3) + self._log.debug(f"Serial number sn2: {sn2}") + self._log.debug(f"Serial number sn3: {sn3}") if not re.search("[^a-zA-Z0-9_]", sn2) : serial_number = sn2 @@ -253,7 +253,7 @@ def analyze_protocol(self, settings_dir : str = "protocols"): if hasattr(self, 'protocolSettings') and self.protocolSettings: max_input_register = self.protocolSettings.registry_map_size[Registry_Type.INPUT] max_holding_register = self.protocolSettings.registry_map_size[Registry_Type.HOLDING] - print(f"Using configured protocol register ranges: input={max_input_register}, holding={max_holding_register}") + self._log.debug(f"Using configured protocol register ranges: input={max_input_register}, holding={max_holding_register}") # Use the configured protocol for analysis protocols[self.protocolSettings.protocol] = self.protocolSettings @@ -271,11 +271,11 @@ def analyze_protocol(self, settings_dir : str = "protocols"): if protocols[name].registry_map_size[Registry_Type.HOLDING] > max_holding_register: max_holding_register = protocols[name].registry_map_size[Registry_Type.HOLDING] - print("max input register: ", max_input_register) - print("max holding register: ", max_holding_register) + self._log.debug(f"max input register: {max_input_register}") + self._log.debug(f"max holding register: {max_holding_register}") self.modbus_delay = self.modbus_delay #decrease delay because can probably get away with it due to lots of small reads - print("read INPUT Registers: ") + self._log.debug("read INPUT Registers: ") input_save_path = "input_registry.json" holding_save_path = "holding_registry.json" @@ -305,14 +305,14 @@ def analyze_protocol(self, settings_dir : str = "protocols"): json.dump(holding_registry, file) #print results for debug - print("=== START INPUT REGISTER ===") + self._log.debug("=== START INPUT REGISTER ===") if input_registry: - print([(key, value) for key, value in input_registry.items()]) - print("=== END INPUT REGISTER ===") - print("=== START HOLDING REGISTER ===") + self._log.debug([(key, value) for key, value in input_registry.items()]) + self._log.debug("=== END INPUT REGISTER ===") + self._log.debug("=== START HOLDING REGISTER ===") if holding_registry: - print([(key, value) for key, value in holding_registry.items()]) - print("=== END HOLDING REGISTER ===") + self._log.debug([(key, value) for key, value in holding_registry.items()]) + self._log.debug("=== END HOLDING REGISTER ===") #very well possible the registers will be incomplete due to different hardware sizes #so dont assume they are set / complete @@ -396,9 +396,9 @@ def evaluate_score(entry : registry_map_entry, val): #print scores for name in sorted(protocol_scores, key=protocol_scores.get, reverse=True): - print("=== "+str(name)+" - "+str(protocol_scores[name])+" ===") - print("input register score: " + str(input_register_score[name]) + "; valid registers: "+str(input_valid_count[name])+" of " + str(len(protocols[name].get_registry_map(Registry_Type.INPUT)))) - print("holding register score : " + str(holding_register_score[name]) + "; valid registers: "+str(holding_valid_count[name])+" of " + str(len(protocols[name].get_registry_map(Registry_Type.HOLDING)))) + self._log.debug("=== "+str(name)+" - "+str(protocol_scores[name])+" ===") + self._log.debug("input register score: " + str(input_register_score[name]) + "; valid registers: "+str(input_valid_count[name])+" of " + str(len(protocols[name].get_registry_map(Registry_Type.INPUT)))) + self._log.debug("holding register score : " + str(holding_register_score[name]) + "; valid registers: "+str(holding_valid_count[name])+" of " + str(len(protocols[name].get_registry_map(Registry_Type.HOLDING)))) def write_variable(self, entry : registry_map_entry, value : str, registry_type : Registry_Type = Registry_Type.HOLDING): diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 88ecf5e..1d05b4f 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -24,18 +24,18 @@ class modbus_rtu(modbus_base): pymodbus_slave_arg = "unit" def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings = None): - print("DEBUG: modbus_rtu.__init__ starting") + self._log.debug("modbus_rtu.__init__ starting") try: super().__init__(settings, protocolSettings=protocolSettings) - print("DEBUG: super().__init__ completed") + self._log.debug("super().__init__ completed") self.port = settings.get("port", "") - print(f"DEBUG: Port from settings: '{self.port}'") + self._log.debug(f"Port from settings: '{self.port}'") if not self.port: raise ValueError("Port is not set") self.port = find_usb_serial_port(self.port) - print(f"DEBUG: Port after find_usb_serial_port: '{self.port}'") + self._log.debug(f"Port after find_usb_serial_port: '{self.port}'") if not self.port: raise ValueError("Port is not valid / not found") @@ -47,12 +47,12 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings # Check for baud rate in config settings (look for both 'baud' and 'baudrate') if "baud" in settings: self.baudrate = settings.getint("baud") - print(f"DEBUG: Using baud rate from config 'baud': {self.baudrate}") + self._log.debug(f"Using baud rate from config 'baud': {self.baudrate}") elif "baudrate" in settings: self.baudrate = settings.getint("baudrate") - print(f"DEBUG: Using baud rate from config 'baudrate': {self.baudrate}") + self._log.debug(f"Using baud rate from config 'baudrate': {self.baudrate}") else: - print(f"DEBUG: Using default baud rate: {self.baudrate}") + self._log.debug(f"Using default baud rate: {self.baudrate}") address : int = settings.getint("address", 0) self.addresses = [address] @@ -61,16 +61,16 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings init_signature = inspect.signature(ModbusSerialClient.__init__) client_str = self.port+"("+str(self.baudrate)+")" - print(f"DEBUG: Client cache key: {client_str}") - print(f"DEBUG: Existing clients in cache: {list(modbus_base.clients.keys())}") + self._log.debug(f"Client cache key: {client_str}") + self._log.debug(f"Existing clients in cache: {list(modbus_base.clients.keys())}") if client_str in modbus_base.clients: - print(f"DEBUG: Using existing client from cache: {client_str}") + self._log.debug(f"Using existing client from cache: {client_str}") self.client = modbus_base.clients[client_str] # Set compatibility flag based on existing client self._set_compatibility_flag() else: - print(f"DEBUG: Creating new client with baud rate: {self.baudrate}") + self._log.debug(f"Creating new client with baud rate: {self.baudrate}") if "method" in init_signature.parameters: self.client = ModbusSerialClient(method="rtu", port=self.port, baudrate=int(self.baudrate), @@ -88,13 +88,13 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings #add to clients modbus_base.clients[client_str] = self.client - print(f"DEBUG: Added client to cache: {client_str}") + self._log.debug(f"Added client to cache: {client_str}") - print("DEBUG: modbus_rtu.__init__ completed successfully") + self._log.debug("modbus_rtu.__init__ completed successfully") # Handle analyze_protocol after initialization is complete if self.analyze_protocol_enabled: - print("DEBUG: analyze_protocol enabled, connecting and analyzing...") + self._log.debug("analyze_protocol enabled, connecting and analyzing...") # Connect to the device first self.connect() @@ -108,7 +108,7 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings quit() except Exception as e: - print(f"DEBUG: Exception in modbus_rtu.__init__: {e}") + self._log.debug(f"Exception in modbus_rtu.__init__: {e}") import traceback traceback.print_exc() raise @@ -177,10 +177,10 @@ def write_register(self, register : int, value : int, **kwargs): self.client.write_register(register, value, **kwargs) #function code 0x06 writes to holding register def connect(self): - print("DEBUG: modbus_rtu.connect() called") + self._log.debug("modbus_rtu.connect() called") # Ensure client is initialized before trying to connect if not hasattr(self, 'client') or self.client is None: - print("DEBUG: Client not found, re-initializing...") + self._log.debug("Client not found, re-initializing...") # Re-initialize the client if it wasn't set properly client_str = self.port+"("+str(self.baudrate)+")" @@ -208,7 +208,7 @@ def connect(self): # Set compatibility flag self._set_compatibility_flag() - print(f"DEBUG: Attempting to connect to {self.port} at {self.baudrate} baud...") + self._log.debug(f"Attempting to connect to {self.port} at {self.baudrate} baud...") self.connected = self.client.connect() - print(f"DEBUG: Connection result: {self.connected}") + self._log.debug(f"Connection result: {self.connected}") super().connect() From df9c9c6daa329dd7295dcc2e3a280bbaf8f6592e Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 14:11:02 -0400 Subject: [PATCH 28/32] cleanup logging, place DEBUG level messages behind debug --- classes/transports/modbus_rtu.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/classes/transports/modbus_rtu.py b/classes/transports/modbus_rtu.py index 1d05b4f..3d2eb71 100644 --- a/classes/transports/modbus_rtu.py +++ b/classes/transports/modbus_rtu.py @@ -24,9 +24,9 @@ class modbus_rtu(modbus_base): pymodbus_slave_arg = "unit" def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings = None): - self._log.debug("modbus_rtu.__init__ starting") try: super().__init__(settings, protocolSettings=protocolSettings) + self._log.debug("modbus_rtu.__init__ starting") self._log.debug("super().__init__ completed") self.port = settings.get("port", "") @@ -108,7 +108,10 @@ def __init__(self, settings : SectionProxy, protocolSettings : protocol_settings quit() except Exception as e: - self._log.debug(f"Exception in modbus_rtu.__init__: {e}") + if hasattr(self, '_log') and self._log: + self._log.debug(f"Exception in modbus_rtu.__init__: {e}") + else: + print(f"Exception in modbus_rtu.__init__: {e}") import traceback traceback.print_exc() raise From a8f606a7cbfbfecd330ef79c9842a89fb1b7e2ad Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 17:26:49 -0400 Subject: [PATCH 29/32] influxdb floating point fixup --- classes/transports/influxdb_out.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/classes/transports/influxdb_out.py b/classes/transports/influxdb_out.py index 24a3028..d0a9589 100644 --- a/classes/transports/influxdb_out.py +++ b/classes/transports/influxdb_out.py @@ -101,15 +101,34 @@ def write_data(self, data: dict[str, str], from_transport: transport_base): # Prepare fields (the actual data values) fields = {} for key, value in data.items(): + # Check if we should force float formatting based on protocol settings + should_force_float = False + + # Try to get registry entry from protocol settings to check unit_mod + if hasattr(from_transport, 'protocolSettings') and from_transport.protocolSettings: + # Check both input and holding registries + for registry_type in [Registry_Type.INPUT, Registry_Type.HOLDING]: + registry_map = from_transport.protocolSettings.get_registry_map(registry_type) + for entry in registry_map: + if entry.variable_name == key: + # If unit_mod is not 1.0, this value should be treated as float + if entry.unit_mod != 1.0: + should_force_float = True + self._log.debug(f"Variable {key} has unit_mod {entry.unit_mod}, forcing float format") + break + if should_force_float: + break + # Try to convert to numeric values for InfluxDB try: # Try to convert to float first float_val = float(value) - # If it's an integer, store as int - if float_val.is_integer(): - fields[key] = int(float_val) - else: + + # If it's an integer but should be forced to float, or if it's already a float + if should_force_float or not float_val.is_integer(): fields[key] = float_val + else: + fields[key] = int(float_val) except (ValueError, TypeError): # If conversion fails, store as string fields[key] = str(value) From fb49a90a33cc2e38903a7d6a6a2011b4aaafe183 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 17:35:10 -0400 Subject: [PATCH 30/32] promote serial number from inverter to device --- classes/transports/canbus.py | 19 +++++++++++++++++++ classes/transports/modbus_base.py | 19 +++++++++++++++++++ classes/transports/serial_pylon.py | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/classes/transports/canbus.py b/classes/transports/canbus.py index 7bc1a62..63363ad 100644 --- a/classes/transports/canbus.py +++ b/classes/transports/canbus.py @@ -240,6 +240,25 @@ def read_data(self) -> dict[str, str]: info.update(new_info) + # Check for serial number variables and promote to device_serial_number + if info: + # Look for common serial number variable names + serial_variable_names = [ + "serial_number", "serialnumber", "serialno", "sn", + "device_serial_number", "device_serial", "serial" + ] + + for key, value in info.items(): + key_lower = key.lower() + if any(serial_name in key_lower for serial_name in serial_variable_names): + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") + self.device_serial_number = str(value) + self.update_identifier() + break + currentTime = time.time() if not info: diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index d5c15b1..b4c5c47 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -203,6 +203,25 @@ def read_data(self) -> dict[str, str]: info.update(new_info) + # Check for serial number variables and promote to device_serial_number + if info: + # Look for common serial number variable names + serial_variable_names = [ + "serial_number", "serialnumber", "serialno", "sn", + "device_serial_number", "device_serial", "serial" + ] + + for key, value in info.items(): + key_lower = key.lower() + if any(serial_name in key_lower for serial_name in serial_variable_names): + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") + self.device_serial_number = str(value) + self.update_identifier() + break + if not info: self._log.info("Register is Empty; transport busy?") diff --git a/classes/transports/serial_pylon.py b/classes/transports/serial_pylon.py index fe42ef9..f70feba 100644 --- a/classes/transports/serial_pylon.py +++ b/classes/transports/serial_pylon.py @@ -120,6 +120,25 @@ def read_data(self): info = self.protocolSettings.process_registery({entry.register : raw}, map=registry_map) + # Check for serial number variables and promote to device_serial_number + if info: + # Look for common serial number variable names + serial_variable_names = [ + "serial_number", "serialnumber", "serialno", "sn", + "device_serial_number", "device_serial", "serial" + ] + + for key, value in info.items(): + key_lower = key.lower() + if any(serial_name in key_lower for serial_name in serial_variable_names): + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") + self.device_serial_number = str(value) + self.update_identifier() + break + if not info: self._log.info("Data is Empty; Serial Pylon Transport busy?") From e7d78b06308f133df77c04d165b7cb30ca9919ae Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 17:48:49 -0400 Subject: [PATCH 31/32] promote serial number from inverter to device --- classes/transports/canbus.py | 2 +- classes/transports/modbus_base.py | 2 +- classes/transports/serial_pylon.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/classes/transports/canbus.py b/classes/transports/canbus.py index 63363ad..ce8ae3b 100644 --- a/classes/transports/canbus.py +++ b/classes/transports/canbus.py @@ -244,7 +244,7 @@ def read_data(self) -> dict[str, str]: if info: # Look for common serial number variable names serial_variable_names = [ - "serial_number", "serialnumber", "serialno", "sn", + "serial_number", "serialnumber", "serialno", "device_serial_number", "device_serial", "serial" ] diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index b4c5c47..20bd2a8 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -207,7 +207,7 @@ def read_data(self) -> dict[str, str]: if info: # Look for common serial number variable names serial_variable_names = [ - "serial_number", "serialnumber", "serialno", "sn", + "serial_number", "serialnumber", "serialno", "device_serial_number", "device_serial", "serial" ] diff --git a/classes/transports/serial_pylon.py b/classes/transports/serial_pylon.py index f70feba..8c49193 100644 --- a/classes/transports/serial_pylon.py +++ b/classes/transports/serial_pylon.py @@ -124,7 +124,7 @@ def read_data(self): if info: # Look for common serial number variable names serial_variable_names = [ - "serial_number", "serialnumber", "serialno", "sn", + "serial_number", "serialnumber", "serialno", "device_serial_number", "device_serial", "serial" ] From d03a843ef3aab8e9aeb6fae3705f68c842b1b018 Mon Sep 17 00:00:00 2001 From: Jared Mauch Date: Fri, 20 Jun 2025 17:51:49 -0400 Subject: [PATCH 32/32] simplify device_serial_number --- classes/transports/canbus.py | 25 +++++++++---------------- classes/transports/modbus_base.py | 25 +++++++++---------------- classes/transports/serial_pylon.py | 25 +++++++++---------------- 3 files changed, 27 insertions(+), 48 deletions(-) diff --git a/classes/transports/canbus.py b/classes/transports/canbus.py index ce8ae3b..72bec8f 100644 --- a/classes/transports/canbus.py +++ b/classes/transports/canbus.py @@ -242,22 +242,15 @@ def read_data(self) -> dict[str, str]: # Check for serial number variables and promote to device_serial_number if info: - # Look for common serial number variable names - serial_variable_names = [ - "serial_number", "serialnumber", "serialno", - "device_serial_number", "device_serial", "serial" - ] - - for key, value in info.items(): - key_lower = key.lower() - if any(serial_name in key_lower for serial_name in serial_variable_names): - if value and value != "None" and str(value).strip(): - # Found a valid serial number, promote it - if self.device_serial_number != str(value): - self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") - self.device_serial_number = str(value) - self.update_identifier() - break + # Look for serial number variable + if "serial_number" in info: + value = info["serial_number"] + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: serial_number)") + self.device_serial_number = str(value) + self.update_identifier() currentTime = time.time() diff --git a/classes/transports/modbus_base.py b/classes/transports/modbus_base.py index 20bd2a8..1745cb5 100644 --- a/classes/transports/modbus_base.py +++ b/classes/transports/modbus_base.py @@ -205,22 +205,15 @@ def read_data(self) -> dict[str, str]: # Check for serial number variables and promote to device_serial_number if info: - # Look for common serial number variable names - serial_variable_names = [ - "serial_number", "serialnumber", "serialno", - "device_serial_number", "device_serial", "serial" - ] - - for key, value in info.items(): - key_lower = key.lower() - if any(serial_name in key_lower for serial_name in serial_variable_names): - if value and value != "None" and str(value).strip(): - # Found a valid serial number, promote it - if self.device_serial_number != str(value): - self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") - self.device_serial_number = str(value) - self.update_identifier() - break + # Look for serial number variable + if "serial_number" in info: + value = info["serial_number"] + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: serial_number)") + self.device_serial_number = str(value) + self.update_identifier() if not info: self._log.info("Register is Empty; transport busy?") diff --git a/classes/transports/serial_pylon.py b/classes/transports/serial_pylon.py index 8c49193..21a1e23 100644 --- a/classes/transports/serial_pylon.py +++ b/classes/transports/serial_pylon.py @@ -122,22 +122,15 @@ def read_data(self): # Check for serial number variables and promote to device_serial_number if info: - # Look for common serial number variable names - serial_variable_names = [ - "serial_number", "serialnumber", "serialno", - "device_serial_number", "device_serial", "serial" - ] - - for key, value in info.items(): - key_lower = key.lower() - if any(serial_name in key_lower for serial_name in serial_variable_names): - if value and value != "None" and str(value).strip(): - # Found a valid serial number, promote it - if self.device_serial_number != str(value): - self._log.info(f"Promoting parsed serial number: {value} (from variable: {key})") - self.device_serial_number = str(value) - self.update_identifier() - break + # Look for serial number variable + if "serial_number" in info: + value = info["serial_number"] + if value and value != "None" and str(value).strip(): + # Found a valid serial number, promote it + if self.device_serial_number != str(value): + self._log.info(f"Promoting parsed serial number: {value} (from variable: serial_number)") + self.device_serial_number = str(value) + self.update_identifier() if not info: self._log.info("Data is Empty; Serial Pylon Transport busy?")