2 Commits

Author SHA1 Message Date
syr4ok a7707aff4b docs: finalize cross-platform support and portability guide
- Updated README.md with comprehensive installation and usage guides.
- Added cross-platform command comparison table (Linux vs Windows).
- Included portability instructions for USB drive execution.
- Verified stable operation on Windows 10/11 (CMD/PowerShell) and Linux (Ubuntu/LMDE 7).
- Synchronized default paths and fixed toggle command examples
2026-04-10 16:26:20 +03:00
syr4ok d6e9ac13d1 refactor: make modbus controller cross-platform and improve locking
- Added cross-platform file locking using msvcrt (Windows) and fcntl (Unix).
- Switched to pathlib for robust path management and automatic directory creation.
- Updated is_host_reachable to support Windows ping syntax.
- Added atexit handler to ensure lock file descriptors are closed properly.

NOTE: Verified on Linux (LMDE 7). Windows compatibility implemented but not yet tested.
2026-04-10 15:27:23 +03:00
4 changed files with 86 additions and 56 deletions
+2
View File
@@ -217,6 +217,8 @@ __marimo__/
# Manual additions # Manual additions
*.ini *.ini
*.lock
*.log
# Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties` # Project files, i.e. `.project`, `.actionScriptProperties` and `.flexProperties`
# should NOT be excluded as they contain compiler settings and other important # should NOT be excluded as they contain compiler settings and other important
+34 -32
View File
@@ -7,9 +7,10 @@ Designed for stability in production environments where reliability is key.
### 📋 Features ### 📋 Features
* **Direct Socket Communication:** No heavy libraries like `pymodbus` required. * **Direct Socket Communication:** No heavy libraries like `pymodbus` required.
* **Instance Protection:** Uses `fcntl` file locking to prevent race conditions during concurrent executions. * **Cross-Platform:** Full support for `Linux` and `Windows`.
* **Health Checks:** Integrated `ICMP` ping check before command execution. * **Health Checks:** Integrated `ICMP` ping check before command execution.
* **Configurable:** All parameters (IP, Port, Channels) are stored in an external `.ini` file. * **Configurable:** All parameters (IP, Port, Channels) are stored in an external `.ini` file.
* **Smart Paths:** Uses `pathlib` for automatic directory creation (e.g., `logs/` folder).
* **Detailed Logging:** Full audit trail of all `ON/OFF/TOGGLE` actions. * **Detailed Logging:** Full audit trail of all `ON/OFF/TOGGLE` actions.
--- ---
@@ -17,9 +18,8 @@ Designed for stability in production environments where reliability is key.
### 💻 Requirements ### 💻 Requirements
| Requirement | Value | | Requirement | Value |
| :--- | :--- | | :--- | :--- |
| **OS** | Linux (Ubuntu, Debian, LMDE, etc.) | | **OS** | Linux, Windows, FreeBSD |
| **Python** | 3.6+ | | **Python** | 3.6+ (standard library only) |
| **Permissions** | Write access to log/lock paths |
--- ---
@@ -32,56 +32,58 @@ Designed for stability in production environments where reliability is key.
| **`[modbus]`** | `coil_base` | Channel base num (`0` or `1`) | `1` | | **`[modbus]`** | `coil_base` | Channel base num (`0` or `1`) | `1` |
| **`[modbus]`** | `timeout` | Socket timeout (sec) (only view) | `1` | | **`[modbus]`** | `timeout` | Socket timeout (sec) (only view) | `1` |
| **`[modbus]`** | `max_channel`| Max channel index (e.g., `7`). `7` for `coil_base=1` too. | `7` | | **`[modbus]`** | `max_channel`| Max channel index (e.g., `7`). `7` for `coil_base=1` too. | `7` |
| **`[paths]`** | `lock_file` | Path to `.lock` file | `/tmp/modbus.lock` | | **`[paths]`** | `lock_file` | Path to `.lock` file | `modbus.lock` |
| **`[paths]`** | `log_file` | Path to `.log` file | `/tmp/modbus.log` | | **`[paths]`** | `log_file` | Path to `.log` file | `logs/modbus.log` |
**Tip**: It's better to specify relative names in default path values,
so that they work "out of the box" everywhere:
--- ---
### ⚙️ Installation & Setup ### ⚙️ Installation & Portability
The tool is fully portable and can be run directly from a USB drive or any local folder without system-wide installation.
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/andsyrovatko/s4k-modbus-relay-controller.git git clone https://github.com/andsyrovatko/s4k-modbus-relay-controller.git
cd s4k-modbus-relay-controller cd s4k-modbus-relay-controller
``` ```
2. Configure the tool: 2. Configure the tool:
Copy the example config and edit it with your device details: Copy the example config and edit it (the script will automatically find it in its own directory):
* `Linux:`
```bash ```bash
cp config.ini.example config.ini cp config.ini.example config.ini && nano config.ini
nano config.ini
``` ```
3. Set executable permissions: * `Windows:`
```bash
copy config.ini.example config.ini
```
(then edit with any text editor)
3. Permissions (`Linux only`):
```bash ```bash
chmod +x modbus_controller.py chmod +x modbus_controller.py
``` ```
(to start script as `./modbus_controller.py` not `python modbus_controller.py`)
--- ---
### 🚀 Usage ### 🚀 Usage
0. Use via cli or call from another soft: The script supports both Windows and Linux syntax. You can run it from any location by specifying the full path.
```bash | Action | Linux | Windows (CMD/PowerShell) | Result |
# Get script help/usage info | :--- | :--- | :--- | :--- |
python3 modbus_controller.py | **Get Status** | `./modbus_controller.py status 1` | `python modbus_controller.py status 1` | Channel 1 is OFF |
# Output: modbus_controller.py <status|on|off|toggle> <channel> | **Turn ON** | `./modbus_controller.py on 1` | `python modbus_controller.py on 1` | Channel 1 is turned ON |
| **Turn OFF** | `./modbus_controller.py off 1` | `python modbus_controller.py off 1` | Channel 1 is turned OFF |
| **Toggle** | `./modbus_controller.py toggle 1` | `python modbus_controller.py toggle 1` | Channel 1 is turned OFF (if was ON) |
# Get channel status ### 📦 Portable Example (Windows)
python3 modbus_controller.py status 8 Run the script directly from your USB drive (e.g. `F:\`) by specifying the full path:
# Output: Channel 8 is OFF ```powershell
python F:\ModBus\modbus_controller.py status 1
# Turn a specific channel ON or OFF ```
python3 modbus_controller.py on 8
# Output: Channel 8 is turned ON
# Toggle channel state
python3 modbus_controller.py toggle 8
# Output: Channel 8 is turned ON
```
--- ---
### ⚠️ Important Note ### [✓] Verified: Tested on Linux (LMDE 7) and Windows 11.
[**!IMPORTANT**]\
This version currently supports Linux only due to the use of fcntl for process locking. Windows support is planned for future releases.
--- ---
+2 -2
View File
@@ -7,5 +7,5 @@ timeout = 1 # Timeout value in seconds
max_channel = 7 # Maximum number of channels of the Modbus device model (7 - 0-7, as 8 channels summary) max_channel = 7 # Maximum number of channels of the Modbus device model (7 - 0-7, as 8 channels summary)
[paths] [paths]
lock_file = /tmp/modbus_controller.lock lock_file = modbus_controller.lock
log_file = /tmp/modbus_controller.log log_file = modbus_logs/modbus_controller.log
Regular → Executable
+48 -22
View File
@@ -4,7 +4,7 @@
# Description : Control Modbus coils via TCP (Modbus RTU over TCP). # Description : Control Modbus coils via TCP (Modbus RTU over TCP).
# Usage : python3 modbus_controller.py <status|on|off|toggle> <channel> # Usage : python3 modbus_controller.py <status|on|off|toggle> <channel>
# Author : syr4ok (Andrii Syrovatko) # Author : syr4ok (Andrii Syrovatko)
# Version : 1.0.0-linux # Version : 1.1.0
# ============================================================================= # =============================================================================
import configparser import configparser
import os import os
@@ -13,7 +13,8 @@ import sys
import time import time
import subprocess import subprocess
import logging import logging
import fcntl from pathlib import Path
import atexit
# Read configuration (variables will be used later in the code) # Read configuration (variables will be used later in the code)
config = configparser.ConfigParser(inline_comment_prefixes=('#', ';')) config = configparser.ConfigParser(inline_comment_prefixes=('#', ';'))
@@ -28,36 +29,60 @@ COIL_BASE = config.getint('modbus', 'coil_base')
TIMEOUT = config.getint('modbus', 'timeout') TIMEOUT = config.getint('modbus', 'timeout')
MAX_CHANNEL = config.getint('modbus', 'max_channel') MAX_CHANNEL = config.getint('modbus', 'max_channel')
LOCK_FILE = config.get('paths', 'lock_file') # We get the path to the folder where the script is located
LOG_FILE = config.get('paths', 'log_file') BASE_DIR = Path(__file__).parent.resolve()
log_dir = os.path.dirname(LOG_FILE) # You can read values in the config, and if they are empty or relative, process them:
if not os.path.exists(log_dir): def get_path(config_value, default_name):
path = Path(config_value)
return path if path.is_absolute() else BASE_DIR / path
LOG_FILE = get_path(config.get('paths', 'log_file'), "modbus.log")
LOCK_FILE = get_path(config.get('paths', 'lock_file'), "modbus.lock")
# Creating a folder for logs (works on both OSes)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Locker init
lock_fp = None
def acquire_lock(lock_file):
global lock_fp
try: try:
os.makedirs(log_dir, exist_ok=True) lock_fp = open(lock_file, 'w')
except OSError as e: if sys.platform == 'win32':
print(f"Error: Cannot create log directory {log_dir}: {e}") import msvcrt
# Use local log file as instead msvcrt.locking(lock_fp.fileno(), msvcrt.LK_NBLCK, 1)
LOG_FILE = "modbus_controller.log" else:
import fcntl
fcntl.flock(lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
atexit.register(lock_fp.close) # Close when leaving
return True
except (OSError, IOError, BlockingIOError):
return False
if not acquire_lock(LOCK_FILE):
print("Another instance is already running.")
sys.exit(1)
# -------- LOCK TEST ----------
# print("Lock acquired! Sleeping for 20 seconds... Quick! Run another instance!")
# time.sleep(20)
# -----------------------------
logging.basicConfig( logging.basicConfig(
filename=LOG_FILE, filename=str(LOG_FILE),
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s", format="%(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d at %H:%M:%S" datefmt="%Y-%m-%d at %H:%M:%S"
) )
lock_fp = open(LOCK_FILE, "w")
try:
fcntl.flock(lock_fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("Another instance is already running.")
sys.exit(1)
def is_host_reachable(host): def is_host_reachable(host):
# Determine the ping parameter based on the operating system
param = "-n" if sys.platform.lower() == "win32" else "-c"
try: try:
result = subprocess.run( result = subprocess.run(
["ping", "-c", "1", "-W", "1", host], ["ping", param, "1", "-w", "1000", host],
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL
) )
@@ -135,11 +160,11 @@ if len(sys.argv) != 3:
command_input = sys.argv[1].lower() command_input = sys.argv[1].lower()
channel_input = sys.argv[2] channel_input = sys.argv[2]
# Converting ON/OFF into a command # Accepting "ON"/"OFF" as commands
if command_input in ['on', 'off', 'toggle', 'status']: if command_input in ['on', 'off', 'toggle', 'status']:
command = command_input command = command_input
else: else:
# Attempt to accept "ON"/"OFF" as a command # Try to accept "ON"/"OFF" in any case as well
if command_input.upper() == 'ON': if command_input.upper() == 'ON':
command = 'on' command = 'on'
elif command_input.upper() == 'OFF': elif command_input.upper() == 'OFF':
@@ -211,3 +236,4 @@ except (socket.timeout, ConnectionRefusedError, OSError) as e:
sys.exit(1) sys.exit(1)
sys.exit(0) sys.exit(0)