mirror of
https://github.com/andsyrovatko/s4k-modbus-relay-controller.git
synced 2026-04-21 22:08:54 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7707aff4b | |||
| d6e9ac13d1 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user