Robot Communication track

Lesson 07 · Lessons

MODBUS Client (Robot = Master)

Gripper control with `MBC_*` and IEEE 754 conversion

Lessons10 min readFree lesson

Previous 06. MODBUS SERVER, the robot was in a controlled position (Slave Server). This episode reverses the position. The robot becomes the Master Client and actively controls the electric gripper, other robots and PLC. With the MBC_* command in the HIWIN robot language, one robot connects to up to 4 servers simultaneously and reads and writes 4 types of data (Discrete Input / Coil / Input Register / Holding Register). Reproduce the entire process on one PC without the actual gripper, with _shared/gripper_sim.py (gripper = Server) and RobotMaster (robot = Client) from _shared/modbus_master.py.

Learning Objectives

  • Explains how the robot actively controls the gripper, PLC, and other robots in the Robot = Master Client structure.
  • Knows the structure in which one robot connects to up to 4 servers simultaneously and the meaning of connection numbers (1 to 4).
  • Write the arguments of connection commands such as MBC_RTU_OPEN/MBC_TCP_OPEN and data commands of MBC_READ_*/MBC_WRITE_* separately.
  • Explains the difference between Bit command (Discrete Input / Coil) and Word command (Input / Holding Register, 'W'/'D' type).
  • Know why you need SWAP_WORD/IEEE754_ENCODE/IEEE754_DECODE (Modbus cannot directly transmit real numbers, byte order differences for each device) and convert them directly.

Target environment

  • Tools: Python 3.10+ / pymodbus 3.6.9 / _shared/modbus_master.py·word_tools.py
  • Platform: Windows PC (simulation) — No actual Robot/PLC/Gripper required. The actual equipment side is HRSS·Caterpillar.
  • Verification: Launch the gripper server (gripper_sim.py) in terminal A, and python src/client_gripper/main.py in terminal B.

Key concepts

1) The robot becomes the “master”

┌──────────────────┐ Demand (read/write request) ─► ┌──────────────────┐
   │  Master / Client │                                │  Slave / Server  │
│ HIWIN robot │ ◄── Response (value / response) │ Gripper·PLC·Robot B │
   └──────────────────┘                                └──────────────────┘
(Active) (Passive)
  • Robot (Master/Client) sends the request first. Write “Open” to the gripper’s Holding Register and read the sensor value from the PLC’s Input Register.
  • The other device (Slave/Server) only responds with the requested value and does not speak first.
  • This is the exact opposite of Episode 06. Part 06: Robot=Server (Passive) / Part 07: Robot=Client (Active). Even for the same robot, the communication role varies depending on the settings.

2) One robot = simultaneous connection of up to 4 servers

One robot can control the gripper (connection 1), PLC (connection 2), vision (connection 3), and another robot (connection 4) simultaneously. Each connection is identified by connection number 1~4, and this number is the first argument of all MBC_* data commands.

Robot ──(Connection 1)── Electric Gripper (RTU)
──(Connection 2)── PLC (TCP)
──(Connection 3)── Vision System (TCP)
──(Connection 4)── Other Robot B (TCP)

3) MBC_* commands at a glance (Manual Table 54)

Category command Use Key Factors
Connect(RTU) MBC_RTU_OPEN Start RTU Client connection (Number of connections 14, SlaveID array, Baud, Parity 'N'/'O'/'E', DataBit 58, StopBit 1/2, COM)
MBC_RTU_CLOSE RTU connection termination (none)
Connection (TCP) MBC_TCP_OPEN Start TCP Client connection (Connection number 1~4, "IP", Port)
MBC_TCP_CLOSE TCP connection terminated (connection number 1~4)
Read Bit MBC_READ_DINPUT Discrete Input Read (connection number, start address, data variable, length 1~48)
MBC_READ_COIL Read Coil (connection number, start address, data variable, length 1~48)
Write Bit MBC_WRITE_COIL Coil writing (connection number, start address, data variable, length 1~48)
Read Word MBC_READ_INPUT Read Input Register (connection number, starting address, data variable, type 'W'/'D', length)
MBC_READ_HOLDING Read Holding Register (connection number, starting address, data variable, type 'W'/'D', length)
Write Word MBC_WRITE_HOLDING Write Holding Register (connection number, starting address, data variable, type 'W'/'D', length)

Starting address is 0-base (starting from 1 in HRSS/Caterpillar screens). Data commands will not work unless you call the Connect command first.

Bit command vs Word command — The key is the data type argument that only exists in the Word command.

Category target area data unit length range
Bit Discrete Input(R), Coil(R/W) 0 or 1 1~48
Word Input Register(R), Holding Register(R/W) 16/32bit integer 'W' Read 1125·Write 1123 / 'D' Read 162·Write 161
  • 'W' (Word) — One 16-bit register remains. Small values ​​like model number, speed %.
  • 'D' (Double Word) — Two consecutive registers combined into 32 bits. Values ​​exceeding 16 bits, such as coordinates and angles. Save order is Low word first, High word last.

4) HRSS MBC_* ↔ RobotMaster correspondence table

RobotMaster takes over the role of robot (client) on the PC. One line of MBC_* in the actual equipment is one line of the method below.

HRSS robot command (actual equipment) RobotMaster Method (💻 PC)
MBC_TCP_OPEN(1,"127.0.0.1",502) with RobotMaster('127.0.0.1', 1503) as m:
MBC_TCP_CLOSE(1) with Automatic at end of block close()
MBC_WRITE_HOLDING(1, addr, var, 'W', n) m.write_holding(addr, [..])
MBC_READ_INPUT(1, addr, var, 'W', n) m.read_input(addr, n)
MBC_READ_HOLDING(1, addr, var, 'W', n) m.read_holding(addr, n)
MBC_WRITE_COIL(1, addr, var, n) m.write_coil(addr, v) / m.write_coils(addr, [..])
MBC_READ_DINPUT(1, addr, var, n) m.read_discrete(addr, n)
MBC_READ_COIL(1, addr, var, n) m.read_coils(addr, n)
SWAP_WORD(v) word_tools.swap_word(v)
IEEE754_ENCODE(f) word_tools.ieee754_encode(f)
IEEE754_DECODE(i) word_tools.ieee754_decode(i)

💻 The actual equipment connects the gripper to RS485 (RTU, port 502), but on the PC, since there is no cable, it imitates TCP + random port (1503). Data model/register address is the same as RTU.

5) Why is the conversion command needed?

Modbus registers only carry 16-bit integers. Neither real numbers (floating point numbers) nor integers exceeding 16 bits can be sent directly. Additionally, the byte order (High/Low word order) is different for each device. So:

command Role
SWAP_WORD Correct the byte order of the other device by exchanging the upper and lower word positions of the 32-bit value
IEEE754_ENCODE Since you cannot send a real number (10.5), convert it to real number → 32bit integer (IEEE754 single precision) and send it
IEEE754_DECODE Restore the received integer back to 32bit integer → real number

Verification values: ieee754_encode(10.5)=1093140480, ieee754_decode(1093140480)=10.5, swap_word(10)=655360. Integer coordinates exceeding 16 bits are processed as 'D' type (Part 06 split_word), and real numbers are processed as IEEE754_*. For more information, see Appendix B (32bit·IEEE754 conversion).

6) Gripper register map (Manual Table 55, XEG-64)

area Address Meaning value
Holding Register (R/W) 1536 Gripper model 2624 = XEG-64
1600 direction 0=Close / 1=Open
1601 mobile administration Unit 0.01 mm (50.00mm = 5000)
1602 Movement speed Unit 0.01 mm/s
1606 Confirmation of execution 1 When written, it operates → After completion, 0 automatically returns
Input Register (R) 769 status 0 Idle / 1 Busy / 2 Reach Pos / 3 Hold / 4~7 Alarm
770 Current location Unit 0.01 mm
771 Firmware version major

⚠️ Completion Judgment Trap: Just look at IR[769]==2(Pos) and the status of the previous action will latch and pass immediately. Be sure to check together IR[769]==2 and HR[1606]==0 (the run flag automatically resets to 0).

7) Overview of 3 application examples (Manual 5.4.5)

Example Target (Slave) connection What robots do
① Gripper XEG-64 Electric Gripper RTU Write direction, administration, and execution with the Holding Register, and read the status and location with the Input Register. Open/Close — Practice in this section
② Other robots Robot B TCP Robot A commands the speed·DO·PTP of robot B (Part 06 register map in opposite direction)
③ Read PLC Mitsubishi FX5U TCP The robot reads and writes the PLC's M(Coil/DInput)·D(I/H Register) device

See as example

src/client_gripper/main.py : Gripper Open/Close cycle

Acting as a robot (Master Client), it checks the gripper model and opens and closes 50.00mm. This is the flow of example ① in manual 5.4.5.1. (see file for full code)

from modbus_master import RobotMaster

def run_gripper(m, direction, stroke_001mm=5000, speed_001mm=5000):
    m.write_holding(1600, direction)      #① Direction (0=close / 1=open)
    m.write_holding(1601, stroke_001mm)   #② Movement stroke (0.01mm)
    m.write_holding(1602, speed_001mm)    #③ Movement speed
    m.write_holding(1606, 1)              #④ Execution trigger
    #⑤ Check IR769==2 and HR1606==0 together (avoid latch trap)
    while not (m.read_input(769, 1)[0] == 2 and m.read_holding(1606, 1)[0] == 0):
        time.sleep(0.05)
    return m.read_input(769, 1)[0], m.read_input(770, 1)[0]

with RobotMaster(args.host, args.port) as m:          # MBC_TCP_OPEN(1,"...",502)
    model = m.read_holding(1536, 1)[0]                 # MBC_READ_HOLDING ... 'W',1 -> 2624
    run_gripper(m, 1, 5000)                            #Open 50.00mm
    run_gripper(m, 0, 5000)                            #close

View Run/verify

Terminal A — Launch Gripper Server

Leave this window on.

cd lecture/_shared
set PYTHONUTF8=1
python gripper_sim.py --port 1503 --host 127.0.0.1
============================================================
XEG Gripper Modbus SERVER Simulator 127.0.0.1:1503
Try controlling it using 1600/1601/1606 with modbus_master.py instead of the robot (Client).
Exit: Ctrl+C
============================================================
Starting gripper logic thread.

Terminal B — Robot (master) cycle execution

cd lecture/06_client/07_modbus_client
set PYTHONUTF8=1
python src/client_gripper/main.py --port 1503

Expected output:

=== Gripper Control Cycle (Robot = Master Client) ===
[①] Check model HR[1536]=2624 -> XEG-64
[②] Open command: HR[1600]=1, HR[1601]=5000, HR[1606]=1
[③] Open completed IR[769]=2(Pos), IR[770]=5000(= 50.00mm), HR[1606]=0(automatic reset)
[④] Close command: HR[1600]=0, HR[1606]=1
[⑤] Close completed IR[769]=2(Pos), IR[770]=0(= 0.00mm)
Gripper control cycle completed ✅

An operation log is recorded on terminal A (server).

▶ Open gripper (target 50.00 mm)
✔ Action completed: Status=2(Pos), Position=50.00 mm
▶ Close gripper (target 0.00 mm)
✔ Action completed: Status=2(Pos), Position=0.00 mm

✅ Checkpoint: Check HR[1536]=2624(XEG-64) → After opening, IR[769]=2(Pos), IR[770]=5000(= 50.00mm), HR[1606] automatically return to 0 → After closing (direction=0), IR[770]=0. The robot has completed one cycle, actively controlling the gripper in both directions and confirming completion by status polling.

Common Mistakes

Q. I checked completion with IR[769]==2, but it passes before the operation even starts.

A. It is a latch trap. After the previous action, IR[769] remains at 2 (Pos). It is 2 for a moment even after sending the next command, so if it only sees 2, it passes immediately. Look at HR[1606], which sets itself to 0 when the gripper operates, and judge it as complete only when IR[769]==2 and HR[1606]==0.

Q. I wrote the real coordinates (10.5) to the register and it is broken.

A. Modbus cannot send real numbers directly. Before sending, change IEEE754_ENCODE(10.5) to an integer (1093140480) and restore it to IEEE754_DECODE on the receiving end. Integer coordinates exceeding 16 bits are processed as 'D' type (or Part 06 split_word).

Q. The gripper does not move or moves to the wrong position.

A. The writing order is wrong. Direction (HR1600), stroke (HR1601), and speed (HR1602) are written first, and the execution trigger (HR1606=1) is written last. The value at the moment the trigger is raised is used.

Q. I have ConnectionError: Failed to connect to slave.

A. Gripper server not running or port mismatch. Check that gripper_sim.py --port 1503 is displayed in terminal A and that the --port parameters match. (Ports below 1024, such as 502, use 1503 due to permission issues.)

Cleanup

  • Robot = Master Client. Actively reads and writes four types of registers of gripper, PLC, and other robots. One robot connects to up to 4 servers simultaneously with connection numbers 1 to 4.
  • The connection is first opened with MBC_RTU_OPEN/MBC_TCP_OPEN, and the data is Bit command (READ_DINPUT/READ_COIL/WRITE_COIL) and Word command (READ_INPUT/READ_HOLDING/WRITE_HOLDING, 'W'/'D').
  • Gripper cycle: Direction/stroke writing → HR[1606]=1 trigger → IR[769]==2 and HR[1606]==0 polling (latch trap avoidance) → Position confirmation with IR[770].
  • Real numbers cannot be sent directly via Modbus: Send after IEEE754_ENCODE (real number → integer, 10.5→1093140480), restore as IEEE754_DECODE. If the byte order is reversed, SWAP_WORD(10→655360).

Try it yourself

Solve the assignment below. The reference solution is shown below.

📖 Manual Source

  • HIWIN Robot Communication User Manual (C25UE801-2211)
  • Ch.5.4.1 Functional Application Notes (p.81, Robot = Client, up to 4 Servers, 4 types of data + conversion)
  • Ch.5.4.2 Function Interface Setting (p.82~85, 4 types of RTU·TCP Client connection)
  • Ch.5.4.3 Application Instruction (p.86~101, Table 54 Instruction Summary, MBC_RTU/TCP_OPEN/CLOSE, READ/WRITE_*)
    • Ch.5.4.4 Data Processing (p.102~105, SWAP_WORD / IEEE754_ENCODE / IEEE754_DECODE)
  • Ch.5.4.5 Example Programs (p.106116, Example① XEG Gripper / Example② Other Robot / Example③ FX5U PLC, Table 5556)
  • 32bit·IEEE754 conversion practice: Appendix B · MBC_* cheat sheet: Appendix A

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/client_gripper/main.pyPython
"""07. MODBUS CLIENT (Robot = Master) — Gripper open/close cycle (client_gripper)

Controls the electric gripper (Slave Server) in the role of a robot (Master Client).
Reproduce the flow of manual 5.4.5.1 example ① (XEG gripper RTU control) with a PC simulator.

Flow:
    ① Model confirmation: Read HR[1536] → 2624(XEG-64)
    ② Open: HR[1600]=1 (open), HR[1601]=5000(50.00mm), HR[1606]=1 (run)
    ③ Wait for completion: IR[769]==2(Pos) and HR[1606]==0(execution flag automatic reset)
    ④ Close: HR[1600]=0 (close), HR[1606]=1 (execute)
    ⑤ Wait for completion: IR[769]==2 and HR[1606]==0

⚠️ Completion judgment trap: If you only see IR[769]==2, the state of the previous action is latched and passes immediately.
   Be sure to check IR[769]==2 and HR[1606]==0 (automatic reset of execution flag).

Prerequisite: Launch the gripper server in another terminal.
    python ../../_shared/gripper_sim.py --port 1503 --host 127.0.0.1
Run:
    python src/client_gripper/main.py --port 1503"""
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
import time  # noqa: E402

from modbus_master import RobotMaster  # noqa: E402


def run_gripper(m, direction, stroke_001mm=5000, speed_001mm=5000):
    """Sends one action (open/close) to the gripper and polls until completion.

    direction: 1=open / 0=close
    Completion judgment: IR[769]==2(Pos) **and** HR[1606]==0 (automatic reset of execution flag).
                If you only look at IR[769], the previous operation state is latched and passes incorrectly."""
    m.write_holding(1600, direction)      #① Direction (0=close / 1=open)
    m.write_holding(1601, stroke_001mm)   #② Movement stroke (0.01mm unit)
    m.write_holding(1602, speed_001mm)    #③ Movement speed (unit of 0.01mm/s)
    m.write_holding(1606, 1)              #④ Execution trigger
    #⑤ Check IR769==2 and HR1606==0 together (avoid latch trap)
    while not (m.read_input(769, 1)[0] == 2 and m.read_holding(1606, 1)[0] == 0):
        time.sleep(0.05)
    return m.read_input(769, 1)[0], m.read_input(770, 1)[0]


def main():
    ap = argparse.ArgumentParser(description="Gripper open/close cycle (robot=master)")
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=1503)
    args = ap.parse_args()

    print("=== Gripper Control Cycle (Robot = Master Client) ===")
    try:
        with RobotMaster(args.host, args.port) as m:   # MBC_TCP_OPEN(1,"...",502)
            #① Check the model
            model = m.read_holding(1536, 1)[0]
            name = "XEG-64" if model == 2624 else f"unknown({model})"
            print(f"[①] Check model HR[1536]={model} -> {name}")

            #② Open 50.00mm
            print("[②] Open command: HR[1600]=1, HR[1601]=5000, HR[1606]=1")
            st, pos = run_gripper(m, 1, 5000)
            print(f"[③] Open completed IR[769]={st}(Pos), IR[770]={pos}(= {pos/100:.2f}mm),"
                  f"HR[1606]={m.read_holding(1606, 1)[0]}(automatic reset)")

            #④ Close
            print("[④] Close command: HR[1600]=0, HR[1606]=1")
            st, pos = run_gripper(m, 0, 5000)
            print(f"[⑤] Close Complete IR[769]={st}(Pos), IR[770]={pos}(= {pos/100:.2f}mm)")

        print("Gripper control cycle completed ✅")
        return 0
    except Exception as e:
        print(f"[Error] {e}", file=sys.stderr)
        print("Verify that the gripper server is up:"
              "python ../../_shared/gripper_sim.py --port "
              f"{args.port} --host {args.host}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())

Assignment

Precedence: For number 1 of the two tasks, first launch the gripper server in another terminal. (No server required for number 2)

cd lecture/_shared
set PYTHONUTF8=1
python gripper_sim.py --port 1503 --host 127.0.0.1

Problem 1 — Monitoring gripper status (IR769) as a human-readable string

  • File name: homework_01/main.py
  • Key concepts: Input register polling, status code mapping, latch trap avoidance

Requirements

  • The robot (Master Client) reads the gripper's IR[769] (state) and outputs the code (0~3) as a human-readable string (Idle/Busy/Pos/Hold).
  • Send an open command (HR[1600]=1, HR[1601]=5000, HR[1606]=1), polling IR[769] until completion, and output whenever the status changes.
  • IR[769]==2 alone is not enough to determine completion. Since the status of the previous operation is latched and passes immediately, check HR[1606]==0 (automatic reset of execution flag) together.
  • Supports argparse --host(127.0.0.1) --port(1503).

Expected output

=== Gripper status monitoring (IR[769]) ===
State before operation IR[769]=0 -> Idle
Send open command -> start polling for status
  IR[769]=0  ->  Idle
  IR[769]=1  ->  Busy
IR[769]=2 -> Pos (position IR[770]=5000 = 50.00mm)
Observed state flow: Idle -> Busy -> Pos
Status monitoring complete ✅

If the gripper is already in the Pos(2) state from the previous exercise, the first line may appear as Pos instead of Idle (latch). This is why you should not judge completion based on IR[769]==2 alone.

Hint

  • Mapping to STATUS = {0: "Idle", 1: "Busy", 2: "Pos", 3: "Hold"} dictionary.
  • Polling condition: m.read_input(769, 1)[0] == 2 and m.read_holding(1606, 1)[0] == 0.
  • If you output only when it is different from the previous value, only the change in status is recorded clearly.

Problem 2 — IEEE754 round-trip conversion / SWAP_WORD verification

  • File name: homework_02/main.py
  • Key concepts: Modbus transmission of real numbers, IEEE754 Encoding/Decoding, word order correction

Requirements

  • Load ieee754_encode / ieee754_decode / swap_word of _shared/word_tools.py.
  • After encoding (real number → integer) the real number 10.5, decode (integer → real number) again and check whether OK/FAIL is the same as the original value. (Other values such as 50.0 as well)
  • Verify that swap_word(10) is 655360 and swap_word(65535) is 4294901760.
  • Operates only with word_tools without pymodbus (no server required).

Expected output

=== IEEE754 round-trip conversion (real -> integer -> real) ===
    10.5 -> encode ->   1093140480 -> decode -> 10.5      OK
    50.0 -> encode ->   1112014848 -> decode -> 50        OK
  -12.25 -> encode ->   3242459136 -> decode -> -12.25    OK

=== SWAP_WORD (High/Low word exchange) ===
swap_word(10) = 655360 (expected 655360) OK
swap_word(65535) = 4294901760 (expect 4294901760) OK

Hint

  • enc = ieee754_encode(10.5) → 1093140480, ieee754_decode(enc) → 10.5.
  • Floating point comparison is abs(dec - value) < 1e-6.
  • swap_word(v) is ((v & 0xFFFF) << 16) | ((v >> 16) & 0xFFFF) — exchange of upper and lower word positions.

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 — Monitor gripper status (IR769) as a human-readable string

Key points:
- The robot (Master Client) monitors the status by polling the gripper’s Input Register IR[769].
- Map IR[769] values ​​(0~3) to Idle/Busy/Pos/Hold strings and output them for humans to read.
- IR[769]==2 alone is not enough to determine completion. The status of the previous action is latched and immediately
  Since it passes, you must also check HR[1606]==0 (execution flag automatic reset).

Common mistakes:
- It is judged as completed by only looking at IR[769]==2 -> It immediately passes the previous latch value.
- Missing bootstrap -> ModuleNotFoundError: modbus_master"""
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
import time  # noqa: E402

from modbus_master import RobotMaster  # noqa: E402

#IR[769] status code -> human-readable string
STATUS = {0: "Idle", 1: "Busy", 2: "Pos", 3: "Hold"}


def status_str(code):
    return STATUS.get(code, f"Alarm({code})")


def main():
    ap = argparse.ArgumentParser(description="Gripper condition monitoring (IR769)")
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=1503)
    args = ap.parse_args()

    print("=== Gripper status monitoring (IR[769]) ===")
    try:
        with RobotMaster(args.host, args.port) as m:
            code = m.read_input(769, 1)[0]
            print(f"Status before operation IR[769]={code} -> {status_str(code)}")

            #Sends an open command, polls for status changes, and prints them.
            m.write_holding(1600, 1)      #open
            m.write_holding(1601, 5000)   # 50.00mm
            m.write_holding(1606, 1)      #run
            print("Send open command -> start polling for status")

            seen = []
            #Check IR769==2 and HR1606==0 together (avoid latch trap).
            #Since Pos(2) is a completion signal, only the in-progress status (0/1) is recorded within the loop.
            #The final Pos arrival is output only once after the loop ends.
            while not (m.read_input(769, 1)[0] == 2 and m.read_holding(1606, 1)[0] == 0):
                code = m.read_input(769, 1)[0]
                if code != 2 and (not seen or seen[-1] != code):
                    seen.append(code)
                    print(f"  IR[769]={code}  ->  {status_str(code)}")
                time.sleep(0.02)

            code = m.read_input(769, 1)[0]
            pos = m.read_input(770, 1)[0]
            if not seen or seen[-1] != code:
                seen.append(code)
            print(f"IR[769]={code} -> {status_str(code)} (position IR[770]={pos} = {pos/100:.2f}mm)")
            print(f"Observed status flow: {' -> '.join(status_str(c) for c in seen)}")

        print("Status monitoring complete ✅")
        return 0
    except Exception as e:
        print(f"[Error] {e}", file=sys.stderr)
        print("Check gripper server: python ../../_shared/gripper_sim.py --port"
              f"{args.port} --host {args.host}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())
homework_02/main.pyPython
"""Answer 2 — IEEE754 round-trip conversion / SWAP_WORD verification

Key points:
- Modbus registers carry only 16-bit integers. Real numbers (floating point numbers) cannot be sent directly.
  IEEE754_ENCODE (real number -> 32bit integer) before transmission, IEEE754_DECODE (integer -> real number) after reception.
- If the High/Low word order is reversed between devices, correct by swapping places with SWAP_WORD.
- Verify hand calculation using only _shared/word_tools.py without pymodbus.

Verification values (Manual 5.4.4):
- ieee754_encode(10.5) = 1093140480, ieee754_decode(1093140480) = 10.5
- swap_word(10) = 655360 (0x0000000A -> 0x000A0000)

Common mistakes:
- If you try to write a mistake directly into the register, it will be broken. Be sure to transmit after IEEE754 encoding.
- Missing bootstrap -> ModuleNotFoundError: word_tools"""
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

from word_tools import ieee754_encode, ieee754_decode, swap_word  # noqa: E402


def roundtrip(value):
    """Real -> Integer (encode) -> Real (decode) round trip verification."""
    enc = ieee754_encode(value)
    dec = ieee754_decode(enc)
    ok = abs(dec - value) < 1e-6
    print(f"{value:>8} -> encode -> {enc:>12} -> decode -> {dec:<8g}  {'OK' if ok else 'FAIL'}")


def check_swap(v, expect):
    s = swap_word(v)
    ok = s == expect
    print(f"swap_word({v}) = {s} (expect {expect}) {'OK' if ok else 'FAIL'}")


def main():
    print("=== IEEE754 round-trip conversion (real -> integer -> real) ===")
    roundtrip(10.5)     # 1093140480
    roundtrip(50.0)     # 1112014848
    roundtrip(-12.25)

    print("\n=== SWAP_WORD (High/Low word exchange) ===")
    check_swap(10, 655360)            # 0x0000000A -> 0x000A0000
    check_swap(65535, 4294901760)     # 0x0000FFFF -> 0xFFFF0000

    print("\n=== Application: Scenario for sending real coordinates to a device in reverse byte order ===")
    coord = 10.5
    enc = ieee754_encode(coord)              #Encoding before transmission
    swapped = swap_word(enc)                 #Opposite equipment word order reverse -> correction
    restored = ieee754_decode(swap_word(swapped))  #Receiving side: Revert and decode
    ok = abs(restored - coord) < 1e-6
    print(f"{coord} -> encode {enc} -> swap {swapped} -> swap-back -> decode {restored}  "
          f"{'OK' if ok else 'FAIL'}")
    return 0


if __name__ == "__main__":
    sys.exit(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