If RS-232 (Part 04) is a point-to-point communication that connects two devices 1:1, MODBUS is an industry standard protocol in which one master reads and writes multiple slaves in register units. This method is used when a PLC controls a robot or when a robot handles an electric gripper or sensor. In this episode, we launch a virtual slave with only a PC and directly look at the four registers and function code read/write as the master.
Learning Objectives
Explains the Master(Client) ↔ Slave(Server) Request/Response structure of MODBUS.
Compare the differences in physical layer and error verification between Modbus RTU and Modbus TCP, and understand why HIWIN robots use RS485 for RTU.
Distinguish the bit width, read/write authority, and value range of the four types of registers (Discrete Input, Coil, Input Register, and Holding Register).
Maps which registers the main Function Code(01~06, 15, 16) reads and writes to.
Confirm that the 4 types of registers cannot be written directly to reading/writing and read-only registers with RobotMaster.
Target environment
Tools: Python 3.10+ / pymodbus 3.6.9
Platform: Windows PC (simulation) — no actual Robot/PLC required
Verification: Slave on terminal A + python src/modbus_basics/main.py on terminal B
Key concepts
1) MODBUS Overview — Master and Slave
MODBUS is one of the most widely used fieldbus protocols in industrial settings and consists of Master(Client) and Slave(Server).
Master(Client) sends Request. ("Give me the value of HR[100]", "Write Coil[300] as 1")
Slave(Server)responds to the request. All slaves receive the command, but only devices with the specified address execute and respond.
The HIWIN robot can be either a master (gripper/sensor control) or a slave (PLC controls the robot). In this episode, the robot will be placed as a slave and connected as a PC master. The practice of running a command cycle with the robot as a slave is 06. MODBUS SERVER.
2) Modbus RTU vs Modbus TCP
MODBUS has three formats: RTU, TCP, and ASCII, and HRSS supports RTU and TCP.
Category
Modbus RTU
Modbus TCP
physical layer
Serial — RS485(HIWIN), RS232/RS422-also possible
Ethernet(LAN/RJ45)
data representation
Binary (binary) transfer
Encapsulate MODBUS data in TCP/IP packets
Error Verification
CRC code required at end of data
Verified by TCP layer → No CRC required
Using HIWIN
RS485 transmission
Ethernet transmission
CRC (Cyclic Redundancy Check): A short, fixed-length verification code is created from the data and appended, and the receiver recalculates it to check for modifications. RTU is a serial raw transmission, so CRC is required, but in TCP, the lower TCP layer guarantees integrity, so the MODBUS level CRC is omitted.
💡 Since this exercise is conducted on one PC, Modbus TCP (127.0.0.1) is used. Since the data model (4 types of registers and function code) is completely the same as the RTU, the concepts learned here are directly applied to the RS485 RTU.
3) 4 types of data registers
The data handled by the function code is stored in 4 types of registers inside the slave. Bit width and read/write permission are key.
register
bit width
Permissions
value range
Remarks
Discrete Input
1 bit
Read Only (R)
0 to 1
External input signal (e.g. robot status output SO)
Coil
1 bit
read/write(R/W)
0 to 1
ON/OFF control (e.g. input signal SI, digital output DO)
Input Register
16 bit word
Read Only (R)
-32768 ~ 32767
Measured value/status value (e.g. speed%, operation status)
A 16-bit word expresses -32768 to 32767 based on 2¹⁵=32768. If the data exceeds 16 bits (e.g. coordinate value), it is divided into two words (L/H) and combined into 32 bits — the conversion method is Appendix (MODBUS calculation) and 06. MODBUS SERVER.
4) Function Code
The master specifies which registers to read and write with Function Code.
Function Code
Features
target register
read/write
01
Read Coil status
Coil
Read
02
Read Discrete Input status
Discrete Input
Read
03
Read Holding Register
Holding Register
Read
04
Read Input Register
Input Register
Read
05
Write single Coil
Coil
writing
06
Write single Holding Register
Holding Register
writing
15
Write multiple Coil
Coil
writing
16
Write multiple Holding Register
Holding Register
writing
Do you see the rules? 01/02 = Read bit (Coil/DI), 03/04 = Read word (HR/IR), 05/06 = Single write, 15/16 = Multiple write. And there is no write function code in the read-only Discrete Input·Input Register (only 02·04 exists). This is the crux of the power difference.
5) MODBUS Data Structure — Part 4
The command data sent by the master consists of the following four parts.
#
part
Role
①
(Slave) Address
All slaves listen, but only devices with this address execute/response
②
Function code
Specifies the function of the instruction (which register is Should-I-read/write)
③
Data
Actual contents such as register value, number to read, value to write, etc.
④
Error check
Verification code for command integrity verification (CRC) — Omitted for Modbus TCP
See as example
Example — src/modbus_basics/main.py: Directly handling 4 types of registers
Create a master with from modbus_master import RobotMaster, read each of the four registers (02/01/04/03), write them into Holding(06)·Coil(05) with R/W permission, and read them again to check the reflection. (see file for full code)
#Core part — Read (02·01·04·03), then write (06·05) and read back to check
with RobotMaster(args.host, args.port, args.unit) as m:
di = [int(b) for b in m.read_discrete(0, 8)] # FC02 Discrete Input
co = [int(b) for b in m.read_coils(0, 8)] # FC01 Coil
ir = m.read_input(524, 1)[0] #FC04 Input Register (operation status)
hr = m.read_holding(100, 1)[0] #FC03 Holding Register (speed setting)
m.write_holding(100, 50) #Write FC06 Holding
assert m.read_holding(100, 1) == [50] #Read back and check
m.write_coil(300, 1) #FC05 Coil Write (DO1 ON)
assert [int(b) for b in m.read_coils(300, 1)] == [1]
💡 The read_* series always returns list. The bit type (DI·Coil) is [0, 1, ...], and the word type (IR·HR) is an integer list. A single value is taken out as [0]. Input Register·Discrete Input, which is read-only, does not have methods write_input/write_discrete (forces read-only**).
View Run/verify
Terminal A — Launch the slave (robot server) and leave it alone.
cd lecture/04_modbus/05_modbus_basics
python ../../_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
============================================================
Robot Modbus SERVER Simulator 127.0.0.1:1502 (Slave ID 1)
Connect to the master (modbus_master.py / QModMaster) and practice the command cycle.
Exit: Ctrl+C
============================================================
Starting the robot logic thread.
Terminal B — Runs the master demo.
cd lecture/04_modbus/05_modbus_basics
python src/modbus_basics/main.py --port 1502
Expected output:
=== MODBUS Basics — Handling 4 types of registers ===
Connect to slave 127.0.0.1:1502 (Slave ID 1).
[Read] Read each of the four registers.
① Discrete Input (FC02) read_discrete(0,8) = [0, 0, 0, 1, 0, 0, 0, 0]
-> Address 3 (SO4=Ready) only 1. The robot is in the ‘Ready’ state.
② Coil (FC01) read_coils(0,8) = [0, 0, 0, 0, 0, 0, 0, 0]
-> SI1~SI8 (input). Not turned on yet, so all 0.
③ Input Register (FC04) read_input(100,1) = [100] (Speed%)
read_input(524,1) = [1] (operation state 1=Idle)
④ Holding Reg (FC03) read_holding(100,1)= [0] (speed setting value)
[Write] Write the value to the Holding Register / Coil with R/W permission.
⑤ Write Holding (FC06) write_holding(100, 50)
Recheck read_holding(100,1) = [50] ✅
⑥ Write Coil (FC05) write_coil(300, 1) (DO1 ON)
Recheck read_coils(300,1) = [1] ✅
------------------------------
4 types of registers read/write demo completed ✅
✅ Checkpoint: If each of the four reads corresponds to 02 / 01 / 04 / 03 Function Code, and all recheck lines after writing are ✅, then the slave has been successfully handled with the combination of "Address + Function Code + Data".
Common Mistakes
Q. ConnectionError: Failed to connect to slave
A. The slave on terminal A is not floating or the port is different. Check whether robot_server_sim.py --port 1502 is running and the master is also set to --port 1502.
Q. I want to write a value to read_input(524) (Input Register), but there is no method.
A. It's not a bug, it's by design. Input Register and Discrete Input are read-only, so there is no write function code itself, and write_input/write_discreteis not in RobotMaster. The value is filled by the slave (robot), so the master only reads it.
Q. read_holding(100, 1) result [50] is compared to the integer 50 and it is False.
A. read_* always returns list. For single values, pull out [0] like m.read_holding(100, 1)[0] and compare.
Q. I wrote a value, but in the next run, I see the previous value instead of 0.
A. The slave maintains the state of its registers while it is turned on. To return to default, press Ctrl+C in terminal A and restart the slave.
Cleanup
MODBUS has a structure of Master (request) ↔ Slave (response), and the command consists of 4 parts: ① Address ② Function code ③ Data ④ Error check (CRC, TCP omitted).
The 4 types of registers are divided into bit type (DI=R, Coil=R/W) and word type (IR=R, HR=R/W, -32768~32767), and the authority is actually enforced.
Function codes are regular as 01/02=bit read, 03/04=word read, 05/06=single write, 15/16=multiple write, and there is no write code in the read-only register.
Try it yourself
Solve the assignment below. The reference solution is shown below.
📖 Manual Source
HIWIN Robot Communication User Manual (C25UE801-2211)
Ch.5.1 MODBUS Communication Specifications — p.37~38 (Master/Slave structure, RTU/TCP comparison, CRC, data structure part 4, 4 types of registers)
Ch.5.2 MODBUS Communication main structure — p.39~40 (Function code, Master/Slave role, RS485/Ethernet wiring)
Example files
The runnable files used in this lesson. Copy them into your own project and experiment.
_shared/gripper_sim.pyPython
"""gripper_sim.py — XEG electric gripper = Modbus SERVER(Slave) simulator
Manual 5.4.5.1 (p.106-111). The robot becomes a Modbus CLIENT (master) and controls the gripper.
In this situation, the PC takes over the ‘gripper’ role. Try controlling it with modbus_master.py instead of the robot.
Register map (XEG-64 standard, decimal address, zero_mode):
Holding Register (Write)
1536 gripper models (2624 = XEG-64)
1552 HOME Reset (Write 1 to reset)
1600 directions (0=close / 1=open)
1601 travel stroke [0.01mm]
1602 Movement speed [0.01mm/s]
1603 Grip stroke [0.01mm]
1604 grip speed [0.01mm/s]
1605 Grip force [%]
1606 Confirm/execute settings (write 1 to start operation → automatically return to 0 after completion)
Input Register (Read)
769 (0x301) Gripper status (0 Idle, 1 Busy, 2 Pos reached, 3 Hold, 4~7 Alarm)
770 Current position [0.01mm]
771 (0x303) Firmware version major
772 (0x304) Firmware version minor
Run: python gripper_sim.py # 0.0.0.0:502 (separate port when running simultaneously with robot server)
python gripper_sim.py --port 1503
Exit: Ctrl+C"""
import argparse
import sys
import threading
import time
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusServerContext,
ModbusSlaveContext,
)
from pymodbus.server import StartTcpServer
CO, DI, HR, IR = 1, 2, 3, 4
ST_IDLE, ST_BUSY, ST_POS, ST_HOLD = 0, 1, 2, 3
REG_STATUS = 769
REG_CUR_POS = 770
class GripperLogic(threading.Thread):
def __init__(self, context, period=0.05):
super().__init__(daemon=True)
self.ctx = context[0]
self.period = period
self.busy = False
self.ticks = 0
self.move_ticks = 8
#Initial values: model, firmware, status Idle
self.ctx.setValues(HR, 1536, [2624])
self.ctx.setValues(IR, REG_STATUS, [ST_IDLE])
self.ctx.setValues(IR, REG_CUR_POS, [0])
self.ctx.setValues(IR, 771, [1, 0]) # FW 1.0
def run(self):
print("Starting gripper logic thread.")
while True:
confirm = self.ctx.getValues(HR, 1606, count=1)[0]
if not self.busy and confirm == 1:
direction = self.ctx.getValues(HR, 1600, count=1)[0]
stroke = self.ctx.getValues(HR, 1601, count=1)[0]
self.busy = True
self.ticks = 0
self.target = stroke if direction == 1 else 0
self.ctx.setValues(IR, REG_STATUS, [ST_BUSY])
print(f"▶ Gripper {'open' if direction == 1 else 'close'}"
f"(target {self.target/100:.2f} mm)")
elif self.busy:
self.ticks += 1
if self.ticks >= self.move_ticks:
self.ctx.setValues(IR, REG_CUR_POS, [self.target])
self.ctx.setValues(IR, REG_STATUS, [ST_POS])
self.ctx.setValues(HR, 1606, [0]) #Confirmation flag automatic reset
self.busy = False
print(f"✔ Action completed: state=2(Pos), position={self.target/100:.2f} mm")
time.sleep(self.period)
def build_context():
store = ModbusSlaveContext(
di=ModbusSequentialDataBlock(0, [0] * 100),
co=ModbusSequentialDataBlock(0, [0] * 100),
hr=ModbusSequentialDataBlock(0, [0] * 1700),
ir=ModbusSequentialDataBlock(0, [0] * 800),
zero_mode=True,
)
return ModbusServerContext(slaves=store, single=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=502)
args = ap.parse_args()
#cp949 Prevent logic thread from dying when outputting ▶/✔ in console.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
context = build_context()
GripperLogic(context).start()
print("=" * 60)
print(f"XEG Gripper Modbus SERVER Simulator {args.host}:{args.port}")
print("Try controlling it using 1600/1601/1606 with modbus_master.py instead of the robot (Client).")
print("Exit: Ctrl+C")
print("=" * 60)
try:
StartTcpServer(context=context, address=(args.host, args.port))
except KeyboardInterrupt:
print("\nServer shutdown.")
if __name__ == "__main__":
main()
_shared/modbus_master.pyPython
"""modbus_master.py — Modbus Master (Client) practice tool
It is used for two purposes.
(1) Command line CLI: Learn 4 data types by directly reading/writing registers of the simulator (slave)
(2) import module: Import and use the RobotMaster class from another practice script
Based on pymodbus 3.6.x. Install: python -m pip install pymodbus==3.6.9
CLI example:
# Assume the robot server simulator (robot_server_sim.py) is running on port 502
python modbus_master.py read-hr 100 1 # Read speed settings
python modbus_master.py write-hr 100 50 # Write at 50% speed
python modbus_master.py read-ir 524 1 # Read operation status
python modbus_master.py read-di 0 8 # SO1~SO8 status
python modbus_master.py write-coil 0 1 # SI1(Start) trigger
python modbus_master.py read-coil 0 8"""
import argparse
import sys
from pymodbus.client import ModbusTcpClient
class RobotMaster:
"""Modbus TCP master wrapper to control robots/slaves."""
def __init__(self, host="127.0.0.1", port=502, unit=1):
self.client = ModbusTcpClient(host, port=port)
self.unit = unit
def __enter__(self):
if not self.client.connect():
raise ConnectionError(f"Failed to connect to slave: {self.client}")
return self
def __exit__(self, *exc):
self.client.close()
#--- Read ---------------------------------------------------------------
def read_holding(self, addr, count=1):
rr = self.client.read_holding_registers(addr, count=count, slave=self.unit)
self._check(rr)
return rr.registers
def read_input(self, addr, count=1):
rr = self.client.read_input_registers(addr, count=count, slave=self.unit)
self._check(rr)
return rr.registers
def read_coils(self, addr, count=1):
rr = self.client.read_coils(addr, count=count, slave=self.unit)
self._check(rr)
return rr.bits[:count]
def read_discrete(self, addr, count=1):
rr = self.client.read_discrete_inputs(addr, count=count, slave=self.unit)
self._check(rr)
return rr.bits[:count]
#---Writing-------------------------------------------------------------
def write_holding(self, addr, values):
if isinstance(values, int):
values = [values]
#Modbus registers are transmitted as 16bit unsigned. returned by split_word()
#Negative words (e.g. High=-5) are also accepted and encoded as 0~65535 (& 0xFFFF).
#The slave interprets it again as a signed value.
values = [v & 0xFFFF for v in values]
rr = self.client.write_registers(addr, values, slave=self.unit)
self._check(rr)
def write_coil(self, addr, value):
rr = self.client.write_coil(addr, bool(value), slave=self.unit)
self._check(rr)
def write_coils(self, addr, values):
rr = self.client.write_coils(addr, [bool(v) for v in values], slave=self.unit)
self._check(rr)
@staticmethod
def _check(rr):
if rr.isError():
raise IOError(f"Modbus error response: {rr}")
def main():
ap = argparse.ArgumentParser(description="Modbus TCP Master CLI")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--unit", type=int, default=1, help="Slave ID")
ap.add_argument("op", choices=[
"read-hr", "write-hr", "read-ir",
"read-coil", "write-coil", "read-di",
])
ap.add_argument("addr", type=int)
ap.add_argument("value", type=int, nargs="?", default=1,
help="The read series is the count, and the write series is the value to write.")
args = ap.parse_args()
try:
with RobotMaster(args.host, args.port, args.unit) as m:
if args.op == "read-hr":
print(m.read_holding(args.addr, args.value))
elif args.op == "write-hr":
m.write_holding(args.addr, args.value)
print(f"HR[{args.addr}] <- {args.value} done")
elif args.op == "read-ir":
print(m.read_input(args.addr, args.value))
elif args.op == "read-coil":
print([int(b) for b in m.read_coils(args.addr, args.value)])
elif args.op == "write-coil":
m.write_coil(args.addr, args.value)
print(f"Coil[{args.addr}] <- {bool(args.value)} done")
elif args.op == "read-di":
print([int(b) for b in m.read_discrete(args.addr, args.value)])
except Exception as e:
print(f"[Error] {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
_shared/README.mdMarkdown
# Shared library (`_shared/`)
This is a Python tool/simulator shared by all parties that reproduces HIWIN robot communication using only a PC.
Instead of duplicating large simulators from piece to piece, we manage them here in one place. (Based on pymodbus 3.6.9 / pyserial 3.5, all execution verification completed)
## Installation
```powershell
cd lecture/_shared
python -m pip install -r requirements.txt
#If Hangul is broken in the Windows console:
set PYTHONUTF8=1 # PowerShell: $env:PYTHONUTF8=1
```
## How to use it in each part
- Run the **standalone simulator** (Robot/Gripper/Vision) directly in a separate terminal, and connect the convenience `main.py` to With-TCP/Socket (no import required).
```powershell
python lecture/_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
```
- **Helper module** (`word_tools`, `modbus_master`) is imported by convenience `main.py`. Path bootstrap:
```python
import os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "_shared"))
from word_tools import split_word
from modbus_master import RobotMaster
```
(Because the edited file location is `GROUP/LEC/src/PROJECT/main.py`, it is 4 levels higher up to `_shared`.)
## File list
| file | Role | How to use |
|------|------|---------|
| `word_tools.py` | 32bit Low/High word segmentation/restoration, SWAP_WORD, IEEE754 conversion | 05, 07, Appendix B |
| `modbus_master.py` | Modbus Master CLI + `RobotMaster` Class | 05, 06, 07 |
| `robot_server_sim.py` | Robot = Modbus slave (implementation of command cycle) | 06, 09 |
| `gripper_sim.py` | XEG electric gripper = Modbus slave | 07, 09 |
| `tcp_echo_server.py` | TCP/IP high-level system (vision) simulator, `{data}` format | 03, 09 |
| `serial_echo.py` | RS-232 serial echo server (virtual COM) | 04 |
| `serial_client.py` | RS-232 serial client (virtual COM) | 04 |
> I/O handshake (Part 02) and integrated controller (Part 09) are included in each part of `src/<project>/main.py`.
## Check quick operation
```powershell
#1) Conversion tool (no equipment required)
python word_tools.py
#2) Launch the robot server (one terminal)
python robot_server_sim.py --port 1502 --host 127.0.0.1
#3) Control as master from another terminal
python modbus_master.py --port 1502 read-di 0 8 # SO1~SO8 (SO4=Ready)
python modbus_master.py --port 1502 read-ir 524 1 #Operation status (1=Idle)
```
> **Port 502 Permissions**: On Windows, port 502 may require administrator privileges. In practice, like `--port 1502`
> If you use port 1024 or higher, the process will proceed without permission issues. It actually uses Robots/equipment 502.
> **Running two simulators simultaneously**: Disconnect the ports. Example) Robot `--port 1502`, Gripper `--port 1503`.
_shared/requirements.txtText
# This lecture practice code dependency
# Install: python -m pip install -r requirements.txt
#
# The API of pymodbus varies depending on the version. This lecture is based on 3.6.x Written/Verified.
pymodbus==3.6.9
pyserial==3.5
_shared/robot_server_sim.pyPython
"""robot_server_sim.py — Robot = Modbus SERVER(Slave) simulator
Manual Ch.5.3 (p.41~80). The situation where the PLC/higher master controls the robot is reproduced on the PC.
Leave this program open and open modbus_master.py or QModMaster (GUI) in another window.
Practice the “instruction cycle” by reading/writing registers.
Command execution procedure (Manual 5.3.3.5):
1) HR[201] <- Command number (e.g. 0=PTP, 1=LIN, 3=JOG, 4=GO HOME)
2) HR[202..] <- parameter
3) HR[200] <- 1 (execution trigger)
4) Robot operates → IR[524] (operation status) is 2(Running) → 1(Idle), IR[200]=1(Success)
5) HR[200] <- 0 (Master resets, prepares for next command)
※ The parameter arrangement of this simulator has been simplified for learning purposes (refer to the PTP annotation below).
For the full parameter table of the actual robot, refer to Table 16 in the manual.
Register summary (zero_mode, address is 0-base):
Discrete Input (read only): 0~ SO1..SOn (SO1=Run, SO2=Held, SO3=Fault, SO4=Ready ...)
Coil (read/write): 0~ SI1..SIn (SI1=Start, SI2=Hold, SI3=Stop, SI4=Enable ...),
300~DO1..DOOn
Input Register (read only): 100 speed%, 200 command status, 201 current command, 300~ joint A1..A6(L/H),
400~ Orthogonal
Holding Reg (read/write): 100 speed setting, 200 execution trigger, 201 command number, 202 ~ parameter
Run: python robot_server_sim.py # 0.0.0.0:502, Slave ID 1
python robot_server_sim.py --port 1502 --unit 1
Exit: Ctrl+C"""
import argparse
import os
import sys
import threading
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from word_tools import split_word, combine_word # noqa: E402
from pymodbus.datastore import ( # noqa: E402
ModbusSequentialDataBlock,
ModbusServerContext,
ModbusSlaveContext,
)
from pymodbus.server import StartTcpServer # noqa: E402
#Use Modbus function code as register type selector (pymodbus convention)
CO, DI, HR, IR = 1, 2, 3, 4
CMD_NAMES = {0: "PTP", 1: "LIN", 2: "CIRC", 3: "JOG", 4: "GO HOME", 5: "T_STOP", 6: "STOP"}
class RobotLogic(threading.Thread):
"""Monitors command triggers in the background and operates the virtual robot."""
def __init__(self, context, unit=1, period=0.05):
super().__init__(daemon=True)
self.ctx = context[unit if not context.single else 0]
self.period = period
self.busy = False
self.ack_wait = False #Wait for the master to reset to HR[200]=0
self.ticks = 0
self.move_ticks = 10 #Approximately 0.5 second virtual travel time
self._init_registers()
def _init_registers(self):
#SO default state: SO4=Ready ON
self.ctx.setValues(DI, 0, [0, 0, 0, 1, 0, 0, 0, 0])
#IR State Reset
self.ctx.setValues(IR, 100, [100]) #speed 100%
self.ctx.setValues(IR, 200, [0, 0]) #Command status=0, current command=0
self.ctx.setValues(IR, 524, [1]) #Operation status = Idle
self.ctx.setValues(IR, 522, [1]) #Motor excitation ON
#Current position (joint/orthogonal) to 0
self.ctx.setValues(IR, 300, [0] * 12)
self.ctx.setValues(IR, 400, [0] * 12)
#--- Helper -------------------------------------------------------------
def _get(self, fc, addr, count=1):
return self.ctx.getValues(fc, addr, count=count)
def _set(self, fc, addr, values):
self.ctx.setValues(fc, addr, values)
def _set_so(self, idx, on):
self._set(DI, idx, [1 if on else 0])
#--- Execute command --------------------------------------------------------
def _start_command(self):
cmd = self._get(HR, 201)[0]
self.current_cmd = cmd
self.busy = True
self.ticks = 0
self._set(IR, 524, [2]) # Running
self._set(IR, 201, [cmd]) #current command
self._set(IR, 200, [0]) #status in progress
self._set_so(0, 1) # SO1=Run ON
print(f"▶ Start command: {cmd} ({CMD_NAMES.get(cmd, '?')})")
def _finish_command(self):
cmd = self.current_cmd
params = self._get(HR, 200, count=20) # HR[200..219]
#params[0]=trigger, [1]=command number, [2]=type, [3..14]=6 axis L/H, [15]=speed...
if cmd in (0, 1): # PTP / LIN
mtype = params[2] # 0=Joint, 1=Cartesian
axis_words = params[3:15] #12 words = 6 axes (L, H)
target = self.ctx_in_eng(axis_words)
if mtype == 0:
self._set(IR, 300, axis_words) #Current joint position = target
print(f"Joint movement completed A1~A6 = {target}")
else:
self._set(IR, 400, axis_words) #Current Cartesian Position = Target
print(f"Orthogonal movement completed X..C = {target}")
spd = params[15] if params[15] else 100
self._set(IR, 100, [spd])
elif cmd == 4: # GO HOME
self._set(IR, 300, [0] * 12)
self._set(IR, 400, [0] * 12)
print("Return to origin completed")
#common finish
self._set(IR, 524, [1]) # Idle
self._set(IR, 200, [1]) # Success
self._set_so(0, 0) # SO1=Run OFF
self.busy = False
self.ack_wait = True
print(f"✔ Command completion: IR[200]=1(Success). Reset the master to HR[200]<-0.")
@staticmethod
def ctx_in_eng(words):
"""[L,H,L,H,...] 12 words -> List of 6 engineering values (0.001 scale)."""
out = []
for i in range(0, 12, 2):
low = words[i] if words[i] < 32768 else words[i] - 65536
high = words[i + 1] if words[i + 1] < 32768 else words[i + 1] - 65536
out.append(round(combine_word(low, high) * 0.001, 3))
return out
#--- Main Loop --------------------------------------------------------
def run(self):
print("Starting the robot logic thread.")
while True:
trig = self._get(HR, 200)[0]
if self.ack_wait:
if trig == 0:
self.ack_wait = False #Confirm master reset → Next commands possible
elif self.busy:
self.ticks += 1
if self.ticks >= self.move_ticks:
self._finish_command()
elif trig == 1:
self._start_command()
time.sleep(self.period)
def build_context():
store = ModbusSlaveContext(
di=ModbusSequentialDataBlock(0, [0] * 600),
co=ModbusSequentialDataBlock(0, [0] * 600),
hr=ModbusSequentialDataBlock(0, [0] * 2100),
ir=ModbusSequentialDataBlock(0, [0] * 600),
zero_mode=True,
)
return ModbusServerContext(slaves=store, single=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=502)
ap.add_argument("--unit", type=int, default=1)
args = ap.parse_args()
#When outputting ▶/✔ from the Windows default console (cp949), UnicodeEncodeError occurs.
#Prevent logic threads from dying (compared to environments where PYTHONUTF8 is not set)
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
context = build_context()
logic = RobotLogic(context, unit=args.unit)
logic.start()
print("=" * 60)
print(f"Robot Modbus SERVER Simulator {args.host}:{args.port} (Slave ID {args.unit})")
print("Connect to the master (modbus_master.py / QModMaster) and practice the command cycle.")
print("Exit: Ctrl+C")
print("=" * 60)
try:
StartTcpServer(context=context, address=(args.host, args.port))
except KeyboardInterrupt:
print("\nServer shutdown.")
if __name__ == "__main__":
main()
_shared/serial_client.pyPython
"""serial_client.py — RS-232 robot client (PC simulation, chapter 03-rs232)
Open the other side (COM5) of the com0com virtual COM port pair to transmit packets and
Receives and outputs the response from the echo server (serial_echo.py, COM6).
In an actual robot, this flow is written as COPEN(SER,HANDLE) -> CWRITE -> CREAD.
Run (serial_echo.py should be floating in another terminal):
python serial_client.py --port COM5 --baud 9600 --value 123"""
import argparse
import serial # pyserial
def main():
ap = argparse.ArgumentParser(description="RS-232 client (send packet and receive response)")
ap.add_argument("--port", default="COM5", help="Open virtual COM port (default COM5)")
ap.add_argument("--baud", type=int, default=9600, help="baud rate (must be the same on both sides)")
ap.add_argument("--value", default="123", help="Value to send (default 123)")
args = ap.parse_args()
ser = serial.Serial(args.port, args.baud, timeout=1)
packet = ("{" + args.value + "}").encode("ascii") #Example: b'{123}'
ser.write(packet)
print(f"[client] {args.port} @ {args.baud}bps -> send {packet!r}")
resp = ser.read(64) #Receive echo server response
print(f"[client] receive {resp!r} (decode: {resp.decode('ascii', errors='replace')})")
ser.close()
if __name__ == "__main__":
main()
_shared/serial_echo.pyPython
"""serial_echo.py — RS-232 echo server (PC simulation, chapter 03-rs232)
Open one side (COM6) of the com0com virtual COM port pair (e.g. COM5 <-> COM6),
The received data is returned by wrapping it in curly braces { } as in the HIWIN packet format.
The PC imitates the RS-232 response flow (role of the upper system) of a real robot.
Run:
python serial_echo.py --port COM6 --baud 9600
Stop: Ctrl+C"""
import argparse
import serial # pyserial
def main():
ap = argparse.ArgumentParser(description="RS-232 echo server (serial reception -> {data} response)")
ap.add_argument("--port", default="COM6", help="Open virtual COM port (default COM6)")
ap.add_argument("--baud", type=int, default=9600, help="baud rate (must be the same on both sides)")
args = ap.parse_args()
#timeout=1: prevents infinite waiting by making read() wait for data only for 1 second.
ser = serial.Serial(args.port, args.baud, timeout=1)
print(f"[echo] {args.port} @ {args.baud}bps opened. Waiting to receive... (Ctrl+C Exit)")
try:
while True:
data = ser.read(64) #Receive up to 64 bytes (if none, b'' after 1 second)
if not data:
continue
text = data.decode("ascii", errors="replace")
reply = "{" + text + "}" #Wrap it in { } like HIWIN packet format
ser.write(reply.encode("ascii"))
print(f"[echo] Receive {data!r} -> Reply {reply!r}")
except KeyboardInterrupt:
print("\n[echo] End")
finally:
ser.close()
if __name__ == "__main__":
main()
_shared/tcp_echo_server.pyPython
"""tcp_echo_server.py — High-level system (vision) simulator for Ethernet TCP/IP communication practice
Manual Ch.3 (p.30~33). The robot operates as a client, and the vision/higher system operates as a server.
In accordance with the HRSS communication protocol, packets are surrounded by curly braces { } and separated by commas.
Sent by robot: {TRIG} (request to shoot)
Server responds: {123.45,67.89,30.0} (X, Y, rotation angle)
Run:
python tcp_echo_server.py # vision mode, 0.0.0.0:6000
python tcp_echo_server.py --mode echo # Returns the received value as is.
python tcp_echo_server.py --port 6000 --host 0.0.0.0
Exit: Ctrl+C"""
import argparse
import socket
def make_vision_reply(request: str) -> str:
"""Simple fake vision results. In reality, the coordinates detected by the camera are included."""
#Different coordinates may be given depending on the request text (e.g. TRIG, COUNT, etc.).
samples = {
"TRIG": "123.45,67.89,30.0",
"TRIG2": "-45.10,210.00,-15.0",
}
return samples.get(request.strip(), "0.00,0.00,0.0")
def handle(conn: socket.socket, addr, mode: str):
print(f"[connection] {addr} connection")
with conn:
buf = ""
while True:
data = conn.recv(1024)
if not data:
break
buf += data.decode("ascii", errors="replace")
#Processed as packet terminator '}'
while "}" in buf:
pkt, buf = buf.split("}", 1)
payload = pkt.replace("{", "").strip()
print(f"Receive: {{{payload}}}")
if mode == "echo":
reply = payload
else:
reply = make_vision_reply(payload)
msg = "{" + reply + "}"
conn.sendall(msg.encode("ascii"))
print(f"Sent from: {msg}")
print(f"[End] {addr}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument("--port", type=int, default=6000)
ap.add_argument("--mode", choices=["vision", "echo"], default="vision")
args = ap.parse_args()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((args.host, args.port))
srv.listen(1)
print(f"Waiting for TCP server {args.host}:{args.port} (mode={args.mode})")
print("Wait for the robot (client) to connect to COPEN (ETH, ...). Exit: Ctrl+C")
try:
while True:
conn, addr = srv.accept()
handle(conn, addr, args.mode)
except KeyboardInterrupt:
print("\nShutting down the server.")
if __name__ == "__main__":
main()
_shared/word_tools.pyPython
"""word_tools.py — HIWIN robot Modbus data conversion practice tool
Refer to manual Appendix II (32bit split storage) and 5.4.4 (SWAP_WORD / IEEE754).
This is a learning module that you can check directly with Python. No need for pymodbus.
Run:
python word_tools.py # Demo (verification of manual example)
python word_tools.py 90.83 # Check split/restore with random real values"""
import struct
import sys
# ---------------------------------------------------------------------------
#1) 32bit value ↔ Low/High word division (Manual Appendix II, p.131~133)
#The robot stores values exceeding 16 bits (coordinates, speed, etc.) divided into two registers.
#Each word is signed 16 bits (-32768~32767).
# ---------------------------------------------------------------------------
def split_word(value: int):
"""signed integer -> (low_word, high_word). Both signed 16bit.
>>> split_word(90830) # A1 = 90.830° (= 90.83 * 1000)
(25294, 1)
>>> split_word(300000) #
(-27680, 4)
>>> split_word(-300000) #
(27680, -5)"""
high = value // 65536 #Quotient (round down if negative → consistent with manual rule)
rem = value % 65536 #Rest: always 0 to 65535
low = rem - 65536 if rem > 32767 else rem #Expressed as signed 16bit
return low, high
def combine_word(low: int, high: int) -> int:
"""(low_word, high_word) -> Original signed integer.
>>> combine_word(25294, 1)
90830
>>> combine_word(-6742, -7) # X = -399.958 mm (Manual example)
-399958"""
return high * 65536 + (low & 0xFFFF) #High maintains sign, low is treated as unsigned
def to_engineering(low: int, high: int, scale: float = 0.001) -> float:
"""Low/High words to actual engineering values. (Coordinates/angle scale=0.001)"""
return combine_word(low, high) * scale
# ---------------------------------------------------------------------------
#2) SWAP_WORD (Manual 5.4.4.1, p.102)
#Exchange the upper/lower word positions of the 32-bit value. (Correct byte order difference by equipment)
# ---------------------------------------------------------------------------
def swap_word(v: int) -> int:
"""High word ↔ Low word exchange.
>>> swap_word(10) # 0x0000000A -> 0x000A0000
655360"""
v &= 0xFFFFFFFF
return ((v & 0xFFFF) << 16) | ((v >> 16) & 0xFFFF)
# ---------------------------------------------------------------------------
#3) IEEE754 single precision conversion (Manual 5.4.4.2 / 5.4.4.3, p.104~105)
#Since Modbus cannot transmit real numbers directly, they are encoded and received as 32-bit integers.
# ---------------------------------------------------------------------------
def ieee754_encode(f: float) -> int:
"""Real number -> 32bit integer (IEEE754 single).
>>> ieee754_encode(10.5)
1093140480"""
return struct.unpack(">I", struct.pack(">f", f))[0]
def ieee754_decode(i: int) -> float:
"""32bit integer (IEEE754 single) -> real number.
>>> ieee754_decode(1093140480)
10.5"""
return struct.unpack(">f", struct.pack(">I", i & 0xFFFFFFFF))[0]
def reg_to_int32(reg_low: int, reg_high: int) -> int:
"""2 registers (unsigned 16bit) -> unsigned 32bit. For IEEE754 decoding preprocessing."""
return ((reg_high & 0xFFFF) << 16) | (reg_low & 0xFFFF)
# ---------------------------------------------------------------------------
#demo
# ---------------------------------------------------------------------------
def _demo():
print("=== 32bit partition/restore (Appendix II) ===")
for eng in (90.830, 300.000, -399.958):
raw = round(eng * 1000)
low, high = split_word(raw)
back = to_engineering(low, high)
print(f" {eng:>10.3f} -> raw={raw:>8} Low={low:>7} High={high:>3}"
f"-> Restore={back:>10.3f} {'OK' if abs(back-eng) < 1e-6 else 'FAIL'}")
print("\n=== SWAP_WORD (5.4.4.1) ===")
print(f"swap_word(10) = {swap_word(10)} (expecting 655360)")
print("\n=== IEEE754 (5.4.4.2/3) ===")
print(f"ieee754_encode(10.5) = {ieee754_encode(10.5)} (expecting 1093140480)")
print(f"ieee754_decode(...) = {ieee754_decode(1093140480)} (expecting 10.5)")
if __name__ == "__main__":
if len(sys.argv) > 1:
val = float(sys.argv[1])
raw = round(val * 1000)
low, high = split_word(raw)
print(f"input {val} (×1000 = {raw})")
print(f" Low word = {low} (0x{low & 0xFFFF:04X})")
print(f" High word = {high} (0x{high & 0xFFFF:04X})")
print(f"restore = {to_engineering(low, high)}")
print(f" IEEE754 = {ieee754_encode(val)}")
else:
_demo()
src/modbus_basics/main.pyPython
"""05. MODBUS basics — Directly read/write 4 types of registers (modbus_basics)
View robot_server_sim.py as a “general Modbus slave” and use 4 types of registers.
After reading each (Function Code 02/01/04/03), enter Holding/Coil with R/W permission.
Enter the value and read it again to check the reflection.
- Discrete Input (R): Function Code 02 — SO (robot status output)
- Coil (R/W): Read Function Code 01 / Write 05 — SI·DO
- Input Register (R): Function Code 04 — Speed%, operation status
- Holding Register(R/W): Read Function Code 03 / Write 06 — Speed setting
First you need to launch the slave in another terminal:
python ../../_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
Run: python src/modbus_basics/main.py --port 1502"""
import os
import sys
#--- Add _shared shared library path (Search for _shared from the top regardless of editing position or depth) ---
_d = os.path.dirname(os.path.abspath(__file__))
while _d != os.path.dirname(_d) and not os.path.isdir(os.path.join(_d, "_shared")):
_d = os.path.dirname(_d)
sys.path.insert(0, os.path.join(_d, "_shared"))
#Prevent Hangul/symbols from breaking or dying in the cp949 console.
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import argparse # noqa: E402
from modbus_master import RobotMaster # noqa: E402
def main():
ap = argparse.ArgumentParser(description="MODBUS 4 register read/write demo")
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1502)
ap.add_argument("--unit", type=int, default=1, help="Slave ID")
args = ap.parse_args()
print("=== MODBUS Basics — Handling 4 types of registers ===")
print(f"Connect to slave {args.host}:{args.port} (Slave ID {args.unit}).\n")
with RobotMaster(args.host, args.port, args.unit) as m:
#--- 1) Read: Read each of the 4 registers ----------------------------
print("[Read] Read each of the four registers.")
di = [int(b) for b in m.read_discrete(0, 8)]
print(f" ① Discrete Input (FC02) read_discrete(0,8) = {di}")
print("-> Address 3 (SO4=Ready) only 1. The robot is in the ‘Ready’ state.")
co = [int(b) for b in m.read_coils(0, 8)]
print(f" ② Coil (FC01) read_coils(0,8) = {co}")
print("-> SI1~SI8 (input). Not turned on yet, so all 0.")
ir_speed = m.read_input(100, 1)[0]
ir_state = m.read_input(524, 1)[0]
print(f"③ Input Register (FC04) read_input(100,1) = [{ir_speed}] (Speed%)")
print(f"read_input(524,1) = [{ir_state}] (operation state 1=Idle)")
hr = m.read_holding(100, 1)[0]
print(f"④ Holding Reg (FC03) read_holding(100,1)= [{hr}] (speed setting value)\n")
#--- 2) Write: Write value to R/W permission register and read again to confirm ----------
print("[Write] Write the value to the Holding Register / Coil with R/W permission.")
#Write speed setting value (Holding Register) — Function Code 06
m.write_holding(100, 50)
print("⑤ Write Holding (FC06) write_holding(100, 50)")
hr_after = m.read_holding(100, 1)[0]
mark = "✅" if hr_after == 50 else "❌"
print(f"Recheck read_holding(100,1) = [{hr_after}] {mark}")
#Turn on digital output DO1 (Coil 300) — Function Code 05
m.write_coil(300, 1)
print("⑥ Write Coil (FC05) write_coil(300, 1) (DO1 ON)")
co_after = [int(b) for b in m.read_coils(300, 1)]
mark = "✅" if co_after == [1] else "❌"
print(f"Recheck read_coils(300,1) = {co_after} {mark}\n")
print("------------------------------")
print("Demo for reading/writing 4 types of registers completed ✅")
if __name__ == "__main__":
main()
Assignment
For both problems, launch slave on terminal A first and then run it.
python ../../_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
Problem 1 — Holding Register Read → Write → Recheck
Try to write to Input Register (e.g. address 524, operating status) and Discrete Input (e.g. address 3, SO4).
These two are read-only, so there is no method write_input/write_discrete in RobotMaster. It catches the call attempt as try/except (or confirms its existence with getattr) and kindly outputs “Cannot write because it is read-only.”
For comparison, the Holding Register (R/W) shows normal writing to confirm the difference in permissions.
Expected output
[1] Attempting to write to a read-only register — this should fail.
[Blocking] input is read-only → there is no write_input() method. (normal by design)
[Blocking] discrete is read-only → there is no write_discrete() method. (normal by design)
Read-only values remain:
read_input(524,1) = [1]
read_discrete(0,8) = [0, 0, 0, 1, 0, 0, 0, 0]
[2] Contrast — R/W register (Holding) is written normally.
write_holding(100, 60) then read_holding(100,1) = [60] OK
Conclusion: The permission table (DI·IR=R, Coil·HR=R/W) is indeed enforced.
Hint
If getattr(m, "write_input", None) is None, there is no write method — this is evidence of “read-only”.
The function code (05/06/15/16) for writing to a read-only register does not exist. There are only 02·04 (read).
This is not a bug, but by design. These values are filled by the slave (robot), so the master only reads them.
Check the correct answer
After solving it yourself, compare it with the reference solution below. The reference solution is annotated with key points and common mistakes.
Show the reference solution
Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.
homework_01/main.pyPython
"""Answer 1 — Read Holding Register → Write → Recheck
Key points:
- The Holding Register (address 100, speed setting value) has R/W permission and can be used by the master.
- Read again (read_holding, FC03) immediately after writing (write_holding, Function Code 06)
It is important to have the habit of “writing and reading back” to see if the values are actually reflected.
- The read_* series always returns a ‘list’. A single value is retrieved with index [0].
Common mistakes:
- A ConnectionError occurs because the slave is not launched first.
- Not checking whether it has been reflected by omitting rechecking after writing.
- Directly comparing the return value [50] of read_holding(100, 1) with the integer 50
(Lists and integers are not the same → take them out as [0] and compare).
Run:
# Terminal 1: python ../../_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
#Terminal 2:
python homework_01/main.py --port 1502"""
import os
import sys
#Add _shared shared library path (search _shared from parent regardless of depth)
_d = os.path.dirname(os.path.abspath(__file__))
while _d != os.path.dirname(_d) and not os.path.isdir(os.path.join(_d, "_shared")):
_d = os.path.dirname(_d)
sys.path.insert(0, os.path.join(_d, "_shared"))
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import argparse # noqa: E402
from modbus_master import RobotMaster # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1502)
ap.add_argument("--unit", type=int, default=1)
args = ap.parse_args()
addr, new_value = 100, 75 #Change speed setting to 75%
with RobotMaster(args.host, args.port, args.unit) as m:
before = m.read_holding(addr, 1)[0]
print(f"read_holding({addr}) = {before}")
m.write_holding(addr, new_value) # Function Code 06
print(f"write_holding({addr}, {new_value}) completed")
after = m.read_holding(addr, 1)[0]
ok = after == new_value
print(f"read_holding({addr}) = {after} {'OK' if ok else 'FAIL'}")
if __name__ == "__main__":
main()
homework_02/main.pyPython
"""Answer 2 — Verifying that read-only registers cannot be written to
Key points:
- Input Register and Discrete Input are ‘read-only (R)’. Even in MODBUS standard
There is no function code to use these two (01/02/03/04 = read only).
- So the master wrapper RobotMaster also has write_input / write_discrete methods.
‘It doesn’t exist at all’. If you try to call it, AttributeError is raised.
- Kindly explain that permissions are enforced at the code level by using try/except.
- On the other hand, the Holding Register is R/W, so it shows that it is being written normally.
Common mistakes:
- Mistaking the rejection of commands such as write-ir / write-di as a 'bug'.
This is not a bug, but normal behavior with enforced read/write permission design.
- The master is trying to overwrite read-only values (operation status/external input signals).
These values are filled by the slave (robot), so they are only read.
Run:
# Terminal 1: python ../../_shared/robot_server_sim.py --port 1502 --host 127.0.0.1
#Terminal 2:
python homework_02/main.py --port 1502"""
import os
import sys
#Add _shared shared library path (search _shared from parent regardless of depth)
_d = os.path.dirname(os.path.abspath(__file__))
while _d != os.path.dirname(_d) and not os.path.isdir(os.path.join(_d, "_shared")):
_d = os.path.dirname(_d)
sys.path.insert(0, os.path.join(_d, "_shared"))
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import argparse # noqa: E402
from modbus_master import RobotMaster # noqa: E402
def try_write_readonly(m, kind, addr, value):
"""Attempts to write to a read-only register. Normally, it should fail because there is no write method."""
method = getattr(m, f"write_{kind}", None) # write_input / write_discrete
if method is None:
print(f"[Blocking] {kind} is read-only → there is no write_{kind}() method. (normal by design)")
return False
try:
method(addr, value)
print(f"[WARNING] {kind} write passed — it should be read-only, which is strange.")
return True
except Exception as e:
print(f"[blocking] Failed to write {kind}: {type(e).__name__}: {e}")
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=1502)
ap.add_argument("--unit", type=int, default=1)
args = ap.parse_args()
with RobotMaster(args.host, args.port, args.unit) as m:
print("[1] Attempting to write to a read-only register — this should fail.")
#Input Register 524 = Operation status (value filled by slave)
try_write_readonly(m, "input", 524, 2)
#Discrete Input 3 = SO4 (Ready, robot status output)
try_write_readonly(m, "discrete", 3, 0)
print("Read-only values remain:")
print(f" read_input(524,1) = {m.read_input(524, 1)}")
print(f" read_discrete(0,8) = {[int(b) for b in m.read_discrete(0, 8)]}")
print("\n[2] Contrast — R/W register (Holding) is written normally.")
m.write_holding(100, 60) # Function Code 06
after = m.read_holding(100, 1)[0]
print(f"write_holding(100, 60) then read_holding(100,1) = [{after}]"
f"{'OK' if after == 60 else 'FAIL'}")
print("\nConclusion: The permission table (DI·IR=R, Coil·HR=R/W) is indeed enforced.")
if __name__ == "__main__":
main()