Robot Communication track

Lesson 05 · Lessons

MODBUS Basics

The four register types and reading/writing them with function codes

Lessons8 min readFree lesson

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.
┌──────────┐ Demand ─────► ┌──────────┐
   │  Master  │                          │  Slave   │
│ (Client) │ ◄───── Response │ (Server) │
   └──────────┘                          └──────────┘
PLC / Top PC Robot / Gripper / Sensor

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)
Holding Register 16 bit word read/write(R/W) -32768 ~ 32767 Setting values/parameters (e.g. speed setting, command number)

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_discrete is 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).
  • RTU = RS485 + Binary + CRC required, TCP = Ethernet + encapsulation + TCP verified. HIWIN uses RS485 in RTU.
  • 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

  • File name: homework_01/main.py
  • Key concept: Write R/W register (FC06), “Write-read-back-check”

Requirements

  • Create a master with from modbus_master import RobotMaster (--host 127.0.0.1, --port 1502).
  • The speed setting value Holding Register address 100 is read first and output.
  • After writing the value as write_holding(100, 75), read it again and check if 75 is reflected with OK/FAIL.

Expected output (based on just starting up the slave)

read_holding(100) = 0 before writing
write_holding(100, 75) completed
After writing read_holding(100) = 75 OK

Hint

  • read_holding returns a list. A single value is taken out as m.read_holding(100, 1)[0].
  • To import modbus_master, there must be 6 lines of _shared navigation bootstrap at the top of main.py.
  • The “before writing” value may be non-zero if it has already been written in a previous exercise. When the slave is restarted, it returns to 0.

Problem 2 — Verify that read-only registers cannot be written to

  • File name: homework_02/main.py
  • Core concepts: read/write permission enforcement, exception handling

Requirements

  • 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()

Learn this lesson with a mentor

Work through the Robot Communication track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now