Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 106 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,106 @@
# modbus-scripts
Examples of interaction with Enapter devices over Modbus communication protocol
## Introduction

This repository contains examples of interaction with Enapter devices over
Modbus communication protocol. Writing holding registers allows to execute
commands (e.g. reboot), set specific parameters or update configuration.

Reading registers (both holding and inputs) allows to get current hardware status
(e.g. switches states, H2 production parameters, timings, etc.), current configuration,
detect configuration problems.

All information regarding registers, events (errors/warnings), etc. is available at [Enapter Handbook](https://handbook.enapter.com).

### Requirements

Python is used as programming language, version >= 3.10 is required.
Please refer to the actual [downloads](https://www.python.org/downloads/) and [documentation](https://www.python.org/doc/).

Git is required to clone the repository.
Please refer to the actual [downloads](https://www.git-scm.com/downloads) and [documentation](https://www.git-scm.com/doc).

[pyModbusTCP](https://pypi.org/project/pyModbusTCP/0.2.1/) package is required.
Please refer to the actual documentation regarding [usage of virtual environments](https://docs.python.org/3/library/venv.html) and [packages installation](https://packaging.python.org/en/latest/tutorials/installing-packages/).

### Running scripts

Please refer to the actual [documentation](https://docs.python.org/3/using/cmdline.html) regarding general information about running Python scripts.

Each script requires two parameters - Modbus IP address and Modbus port.
IP address is required parameter, default port is _502_.

**Running script with default port:**
```
python3 <path_to_script>/<script_name>.py --modbus-ip <address>
```

**Running script with custom port:**
```
python3 <path_to_script>/<script_name>.py --modbus-ip <address> --modbus-port <port>
```

### Scripts description

**_read_el_control_board_serial.py_**

Read and decode control board serial number input (6) to a human-readable string value (e.g. '9E25E695-A66A-61DD-6570-50DB4E73652D').

**_read_el_device_model.py_**

Read and decode device model input register (0) to a human-readable string value (e.g. 'EL21', 'EL40', etc.).

**_read_el_errors.py_**

Read and decode errors input register (832) to a list of human-readable strings with error name and hex
value (e.g. 'WR_20 (0x3194)'). Since new firmwares may add new events, UNKNOWN errors may be identified by hex value.

**_read_el_params.py_**

Read and decode current hardware parameters:
- system state (input, 18)
- uptime (input, 22)
- total H2 production (input, 1006)
- production rate (holding, 1002)
- high electrolyte level switch (input, 7000)
- very high electrolyte level switch (input, 7001)
- low electrolyte level switch (input, 7002)
- medium electrolyte level switch (input, 7003)
- electrolyte tank high pressure switch (input, 7004)
- electronic compartment high temperature Switch (input, 7007)
- chassis water presence switch (input, 7009)).

**_run_el_maintenance.py_**

Interactive script to perform maintenance on EL2.1/4.x by following the instructions in console.

**ATTENTION!** Maintenance requires manual actions with electrolyser such as electrolyte draining,
flushing (for 4.x) and refilling.

If script is terminated for some reason (e.g. due to network failure), in most cases it can be re-run and
maintenance will continue.

Only refilling is performed in case of first maintenance (from factory state).

**_write_el_production_rate.py_**

- Read current value of the production rate percent holding register (1002)
- Write random value in 90-99 range
- Read register again to check that it contains new value

- write_el_reboot.py

- Write 1 to reboot holding register (4)
- Wait until electrolyser is rebooted
- Read state input register (1200)

**_write_el_syslog_skip_priority.py_**

- Read current value of the log skip priority holding register (4042)
- Check that there is no other configuration in progress (read configuration in progress input register (4000))
- Begin configuration (write 1 to the configuration begin holding register (4000))
- Ensure that configuration source is Modbus (read configuration over modbus input register (4001))
- Write random value in 0-6 range (excluding current value) to the log skip priority holding register (4042)
- Check that configuration is OK (read configuration last result input register (4002))
- Read log skip priority holding register (4042) again to check that it contains new value

NOTICE. Log skip priority holding register (4042) has int32 type, so it may contain any value in the appropriate
range. Values less than 0 are considered as DISABLE_LOGGING (0), values greater than 6 are considered as ALL_MESSAGES (6).
115 changes: 115 additions & 0 deletions read_el_control_board_serial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Copyright 2024 Enapter
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
# or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import sys
import uuid

from typing import Final

try:
from pyModbusTCP import client, utils

except ImportError:
print(
'No pyModbusTCP module installed.\n.'
'1. Create virtual environment\n'
'2. Run \'pip install pyModbusTCP==0.2.1\''
)

raise


# Supported Python version
MIN_PYTHON_VERSION: Final[tuple[int, int]] = (3, 10)

# Register address
CONTROL_BOARD_SERIAL_INPUT: Final[int] = 6


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='Reading EL control board serial with Modbus'
)

parser.add_argument(
'--modbus-ip', '-i', help='Modbus IP address', required=True
)

parser.add_argument(
'--modbus-port', '-p', help='Modbus port', type=int, default=502
)

return parser.parse_args()


def main() -> None:
if sys.version_info < MIN_PYTHON_VERSION:
raise RuntimeError(
f'Python version >='
f' {".".join(str(version) for version in MIN_PYTHON_VERSION)} is'
f' required'
)

args: argparse.Namespace = parse_args()

modbus_client: client.ModbusClient = client.ModbusClient(
host=args.modbus_ip, port=args.modbus_port
)

try:
# Read control board serial input register, address is 6. Register type
# is uint128, so number of registers to read is 128 / 16 = 8.
raw_board_serial: list[int] = modbus_client.read_input_registers(
reg_addr=CONTROL_BOARD_SERIAL_INPUT, reg_nb=8
)

print(f'Got raw control board serial data: {raw_board_serial}')

# Convert raw response to single int value. pyModbusTCP utils has no
# built-in method for uint128, combining with 'manual' conversion.
long_long_list: list[int] = utils.word_list_to_long(
val_list=raw_board_serial, long_long=True
)

converted_board_serial: int = (
long_long_list[0] << 64 | long_long_list[1]
)

print(
f'Got converted int value: {converted_board_serial}'
)

# Decode converted int to human-readable mainboard id which in fact is
# string representation of UUID.
decoded_board_serial: str = str(
uuid.UUID(int=converted_board_serial)
).upper()

print(
f'Got decoded human-readable serial number: {decoded_board_serial}'
)

except Exception as e:
# If something went wrong, we can access Modbus error/exception info.
# For example, in case of connection problems, reading register will
# return None and script will fail with error while data converting,
# but real problem description will be stored in client.
print(f'Exception occurred: {e}')
print(f'Modbus error: {modbus_client.last_error_as_txt}')
print(f'Modbus exception: {modbus_client.last_except_as_txt}')

raise


if __name__ == '__main__':
main()
129 changes: 129 additions & 0 deletions read_el_device_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Copyright 2024 Enapter
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
# or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import sys

from enum import StrEnum
from typing import Any, Final, Self

try:
from pyModbusTCP import client, utils

except ImportError:
print(
'No pyModbusTCP module installed.\n.'
'1. Create virtual environment\n'
'2. Run \'pip install pyModbusTCP==0.2.1\''
)

raise


# Supported Python version
MIN_PYTHON_VERSION: Final[tuple[int, int]] = (3, 10)

# Register address
DEVICE_MODEL_INPUT: Final[int] = 0


class DeviceModel(StrEnum):
"""
Values for ProjectId input register (0).
"""

UNKNOWN = 'UNKNOWN'

# Specific value for EL21.
EL21 = 'EL21'

# Specific values for EL40.
EL40 = 'EL40'
ES40 = 'ES40'

# Specific value for EL41.
ES41 = 'ES41'

@classmethod
def _missing_(cls, value: Any) -> Self:
return cls.UNKNOWN


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description='Reading EL device model with Modbus'
)

parser.add_argument(
'--modbus-ip', '-i', help='Modbus IP address', required=True
)

parser.add_argument(
'--modbus-port', '-p', help='Modbus port', type=int, default=502
)

return parser.parse_args()


def main() -> None:
if sys.version_info < MIN_PYTHON_VERSION:
raise RuntimeError(
f'Python version >='
f' {".".join(str(version) for version in MIN_PYTHON_VERSION)} is'
f' required'
)

args: argparse.Namespace = parse_args()

modbus_client: client.ModbusClient = client.ModbusClient(
host=args.modbus_ip, port=args.modbus_port
)

try:
# Read ProjectId input register, address is 0. Register type is uint32,
# so number of registers to read is 32 / 16 = 2.
raw_device_model: list[int] = modbus_client.read_input_registers(
reg_addr=DEVICE_MODEL_INPUT, reg_nb=2
)

print(f'Got raw device model data: {raw_device_model}')

# Convert raw response to single int value with pyModbusTCP utils.
converted_device_model: int = utils.word_list_to_long(
val_list=raw_device_model
)[0]

print(f'Got converted int value: {converted_device_model}')

# Decode converted int to human-readable device model.
decoded_device_model: DeviceModel = DeviceModel(
bytes.fromhex(f'{converted_device_model:x}').decode()
)

print(
f'Got decoded human-readable device model: {decoded_device_model}'
)

except Exception as e:
# If something went wrong, we can access Modbus error/exception info.
# For example, in case of connection problems, reading register will
# return None and script will fail with error while data converting,
# but real problem description will be stored in client.
print(f'Exception occurred: {e}')
print(f'Modbus error: {modbus_client.last_error_as_txt}')
print(f'Modbus exception: {modbus_client.last_except_as_txt}')

raise


if __name__ == '__main__':
main()
Loading