Robot Communication track

Lesson 08 · Lessons

Monitoring & Debugging

Capturing and manually decoding packets with Wireshark

Lessons12 min readFree lesson
  1. In [MODBUS SERVER, the robot is connected to Server, 07. In [MODBUS CLIENT, the robot was the Client. In both cases, invisible bytes travel over TCP. At the moment when communication “isn’t working and I don’t know why,” debugging is all about seeing these invisible bytes and being able to decipher them by hand. This episode does the same thing with both tools. 🤖 On the actual equipment, view transmitted and received messages on the HRSS/Caterpillar Modbus Monitor screen, 💻 On the PC, capture actual Modbus/TCP packets with Wireshark. And so that you can practice without hardware, src/modbus_monitor/main.py provides automatic verification with Destruction of Damage that decodes hexadecimal frames for human readability.

Learning Objectives

  • I know that the Modbus packet has a [Function Code][Data] structure, and Slave Address·Function Code·Data is displayed in hexadecimal on the monitor.
  • The Master transmission / slave response format of 6 types of function codes (02h, 01h, 0Fh, 04h, 03h, 10h) are classified in a table, and the raw byte can be decoded by hand.
  • Byte number rule — Bit response = ⌈Count/8⌉ (round up), Word response = number × 2 — predict the response length.
  • 🤖 Perform the HRSS/Caterpillar Modbus Monitor setup procedure (Expert → Display → Fieldbus → Modbus Monitor, Show message window, Channel/Server, Send/Export).
  • 💻 Capture local (loopback) Modbus/TCP traffic with Wireshark + Npcap, and force the non-standard port (1502) to be interpreted as Decode As… → Modbus/TCP.

Target environment

  • Tools: Python 3.10+ (Damage reader standard library only, port/equipment not required) / Wireshark + Npcap (live capture optional)
  • Platform: Windows PC (simulation) — No actual Robot/PLC required. The actual equipment side is HRSS·Caterpillar Modbus Monitor.
  • Verification: CLI automatic execution — python src/modbus_monitor/main.py (Decoding 3 manual calculation examples as is)

Key concepts

1) What you see on the monitor is the “meat” of the packet

Modbus packets have different shells depending on the transport method (RTU vs TCP), but the content (Slave Address + Function Code + Data) is the same. This is exactly what Monitor/Wireshark shows.

RTU message: [Slave Addr][Function Code][Data][CRC]
TCP message: [Transaction ID][Protocol ID][Length][Unit ID][Function Code][Data]
└──────────── MBAP header (TCP only) ────────────┘ └── This is the key ──┘
  • TCP has no CRC (TCP layer guarantees integrity). The RTU has CRC 2 bytes at the end.
  • HRSS/Caterpillar What the Modbus Monitor displays is the core of the table above, i.e. Function Code + Data. All numbers are displayed as hexadecimal (h at the end), with up to 200 lines visible on the screen.
  • Wireshark shows everything, including the TCP header, but if you turn on Modbus desector, it decomposes Function Code / Reference Number / Word·Bit Count / Data. The same value as the manual hand calculation should come out.

2) 🤖 HRSS/Caterpillar Modbus Monitor Procedure (Manual 5.5.1)

In the field without Wireshark, the same information is viewed on the controller screen.

  1. Switch user mode to Expert → Main Menu → Display → Fieldbus → Modbus Monitor.
  2. Check Show message window (if not checked, the message will not be displayed and the screen will continue to update).
  3. Select Channel(Channel1/Channel2). If it is the client side, select Server number (1~4) and send packet to Send.
  4. A hexadecimal message (up to 200 lines) appears on the screen in the form of Send to server / Receive from client.
  5. To record, insert a USB disk into the controller and save the log as Export. (For Caterpillar, use the Modbus Monitor tab at the bottom, and the procedure is the same.)

Connect/disconnect-diagram is logged (e.g. Channel1 Modbus TCP server successful connection / disconnection). If connection does not appear, it is narrowed down to a Physical/Network problem. If the query goes after connection but Receive does not appear, it is narrowed down to a Address/Authority problem.

3) 6 types of Function Codes — 4 Bits / 2 Words

FC Meaning target direction
02h Read Discrete Inputs Discrete Input Read (Bit)
01h Read Coils Coil Read (Bit)
0Fh Write Multiple Coils Coil Write (Bit)
04h Read Input Registers Input Register Reading (Word)
03h Read Holding Registers Holding Register Reading (Word)
10h Write Multiple Registers Holding Register Writing (Word)

Two key rules — ① Number of bytes in Bit response = ⌈Count/8⌉(rounded up), 8 bits are 1 byte. ② Number of bytes in Word response = Number × 2, 1 word = 2 bytes. If you know these two, you can predict any response length.

4) Transmission/reception format for each function code (Function Code + Data part only, all hexadecimal)

Action direction FC Data structure (bytes)
Discrete Input Multiple Reading Master transmission 02h [Start address (2)] [Number (2)]
Slave response 02h [number of bytes (1)] [Bit status (number of bytes × 1)]
Coil multi-read Master transmission 01h [Start address (2)] [Number (2)]
Slave response 01h [number of bytes (1)] [Bit status (number of bytes × 1)]
Coil multi-write Master transmission 0Fh [Start address (2)] [Number (2)] [number of bytes (1)] [Bit status]
Slave response 0Fh [Start address (2)] [Number (2)]
Input Register multiple reading Master transmission 04h [Start address (2)] [Number (2)]
Slave response 04h [number of bytes (1)] [Register value (=number × 2)]
Holding Register Multiple Reading Master transmission 03h [Start address (2)] [Number (2)]
Slave response 03h [number of bytes (1)] [Register value (=number × 2)]
Holding Register Multiple Write Master transmission 10h [Start address (2)] [Number (2)] [number of bytes (1)] [Register value (=number × 2)]
Slave response 10h [Start address (2)] [Number (2)]

Read is where the Master sends “some and all” and the Slave returns the data. Write (0Fh·10h) sends data to the Master transmission, and only the “start address + number” is echoed in the slave response (written data is not sent again). If the most significant bit of the response FC is 1 (e.g. 02h→82h), it is Error Response and the next 1 byte is Exception Code (02h invalid address / 03h invalid value / 01h unsupported function).

5) 3 types of hand calculation examples (as in the original text of the manual)

Decode directly by applying the gray rule (Bit=⌈Count/8⌉, Word=Number×2).

① 02h — Discrete Input multiple reading (request)

 02 01 2C 00 05
   02h    → Function Code: Read Discrete Inputs
01 2C → Start address = 0x012C = 300
00 05 → Number = 5

② 04h — Input Register multiple reading (response)

 04 04 00 5A 00 00
   04    → FC 04h Read Input Registers
04 → Number of bytes = 2 × 2 = 4 → 2 registers
00 5A → 0x005A = 90 (first register)
00 00 → 0x0000 = 0 (second register)

③ 10h — Holding Register multiple write (request)

 10 00 C9 00 03 06 00 6F 00 DE 01 4D
   10h    → Write Multiple Registers
00 C9 → Start address = 0x00C9 = 201
00 03 → Count = 3
06 → Number of bytes = 3 × 2 = 6
   00 6F  → 0x006F = 111
   00 DE  → 0x00DE = 222
   01 4D  → 0x014D = 333
(Slave response is 10 00 C9 00 03 — only the starting address + number is echoed, data is omitted)

See as example

src/modbus_monitor/main.py: Modbus frame damage control

When you enter a hexadecimal frame, the function code, starting address, number, number of bytes, and data are decoded so that a human can read them. Since it uses only the standard library and does not require a port, In-Monitor/Wireshark is used to verify the byte as is. (see file for full code)

#Core part — Fields are interpreted for each FC, and the read response is combined into a 16-bit value equal to the number of bytes.
FC_NAMES = {0x02: "Read Discrete Inputs", 0x04: "Read Input Registers",
            0x10: "Write Multiple Registers", ...}

def decode_frame(text, kind="auto"):
    data = parse_hex(text)            # '02 01 2C 00 05' -> [0x02,0x01,0x2C,0x00,0x05]
    fc = data[0]
    #Request (read): [Start address (2)] [Number (2)]
    start = (data[1] << 8) | data[2]  # 0x012C = 300
    count = (data[3] << 8) | data[4]  # 5
    #Response (Word reading): [number of bytes (1)][register value...] -> value = (hi<<8)|lo
    ...

decode_frame("02 01 2C 00 05", "request")   #FC=02h, starting address 300, number 5
decode_frame("04 04 00 5A 00 00", "response")  #number of bytes 4, value [90, 0]
decode_frame("10 00 C9 00 03 06 00 6F 00 DE 01 4D", "request")  #address 201, value [111,222,333]

View Run/verify

CLI automatic verification (no hardware/port required)

cd lecture/07_debug/08_monitoring
set PYTHONUTF8=1
python src/modbus_monitor/main.py

Expected output:

=== Modbus frame damage ===
--- ① Request 02h ---
Input frame: 02 01 2C 00 05
  FC=0x02 (Read Discrete Inputs)
Starting address = 0x012C = 300
count = 5

--- ② Response 04h ---
Input frame: 04 04 00 5A 00 00
  FC=0x04 (Read Input Registers)
number of bytes = 4
value (decimal) = [90, 0]

--- ③ Request 10h ---
Input frame: 10 00 C9 00 03 06 00 6F 00 DE 01 4D
  FC=0x10 (Write Multiple Registers)
Starting address = 0x00C9 = 201
count = 3
Number of bytes = 6
value (decimal) = [111, 222, 333]
------------------------------
Frame decoding verification passed ✅

✅ Checkpoint: If the three frames are solved with start address 300, value [90, 0], start address 201 / value [111, 222, 333], and the last line is Frame decoding verification passed ✅ and end code 0, the manual hand calculation and code match.

💻 Wireshark Live Capture Procedure (Optional)

To verify that actual packets appear to have the same bytes, create and capture traffic with a simulator.

Terminal 1 — Run the robot server on port 1502 (leave it on).

cd lecture/_shared
set PYTHONUTF8=1
python robot_server_sim.py --port 1502 --host 127.0.0.1

Wireshark preset (must be first!) — Local (127.0.0.1 ↔ 127.0.0.1) communication does not go through the physical LAN card.

  1. Install Npcap — Check Install Npcap in the Wireshark installation wizard (loopback capture required).
  2. Capture Interface — Double-click Adapter for loopback traffic capture (or Npcap Loopback Adapter) on the start screen.
  3. Display Filter — Enter tcp.port == 1502 in the top bar and press Enter.

Terminal 2 — Creates traffic by reading 8 Discrete Inputs as Master.

cd lecture/_shared
python modbus_master.py --port 1502 read-di 0 8
[0, 0, 0, 1, 0, 0, 0, 0]
  1. Non-standard port decoding — Wireshark automatically interprets only TCP 502 as Modbus. Right-click the captured packet → Decode As… → Set the Current column of the port (1502) row to Modbus/TCP → OK.

Query/Response 2 packets are caught, and when you expand the detailed tree, you can see the same value as the damage control.

packet Wireshark display raw meaning
Query (→Server) Func 2 Read Discrete Inputs / Reference 0 / Bit Count 8 02 00 00 00 08
Response (←Server) Func 2 / Byte Count 1 / Data 0x08 02 01 08

0x08 = 0b0000 1000 → Among addresses 0 to 7, Only address 3 is On → Matches CLI output [0,0,0,1,0,0,0,0]. You can also check this raw byte by putting it in a loss machine (decode_frame("02 01 08", "response")).

By executing a command that writes multiple values (e.g. write_holding(201, [111,222,333]) in Chapter 06), you can directly capture the 10 00 C9 00 03 06 … packet in exercise ③ and solve it using loss reading.

Common Mistakes

Q. No packets are detected in Wireshark at all (127.0.0.1 ↔ 127.0.0.1).

A. Local communication does not go through a physical LAN card. Change the capture interface to Adapter for loopback traffic (Npcap Loopback). If it is not in the list, check the Npcap + loopback option when reinstalling Wireshark.

Q. The packet is captured, but the Protocol column only shows TCP, not Modbus/TCP.

A. Wireshark automatically interprets 502 port only as Modbus. In the lab, it is 1502, so right-click on the packet → Decode As… → specify port 1502 as Modbus/TCP.

Q. I keep getting confused between the number of bytes and the number of registers.

A. Word is 2 bytes per word. The second byte of the response is the number of bytes, and the number of registers is Number of bytes ÷ 2. Example: 04 04 00 5A 00 00 is byte 4 → 2 registers([90, 0]). Bit, on the other hand, is rounded up to Number/8.

Q. The response FC comes out like 0x82, 0x83.

A. This is Error Response. The FC highest bit is turned on as 1, and the next byte is Exception Code (02h Illegal Data Address=out-of-range address, 03h Illegal Data Value=invalid count/value, 01h Illegal Function=unsupported function). Check address, length, and availability.

Cleanup

  • The content of the Modbus packet is [Function Code][Data]. Monitor/Wireshark appears as hexadecimal, and the HRSS monitor displays up to 200 lines.
  • 6 types of FC: Read 02h(DI)·01h(Coil)·04h(IR)·03h(HR), Write 0Fh(Coil)·10h(HR). Data in read response, Start address + number of echoes in write response.
  • Length rule: Bit response byte = ⌈Count/8⌉, Word response byte = Number×2. Verify manual examples 02 01 2C 00 05, 04 04 00 5A 00 00, and 10 00 C9 00 03 06 … using damage control.
  • 🤖 HRSS/Caterpillar Modbus Monitor: Expert → Display → Fieldbus → Modbus Monitor, Show message window, select Channel/Server and Send, USB Export.
  • 💻 Wireshark: Npcap + loopback adapter, non-standard ports Decode As → Modbus/TCP, filter tcp.port == 1502. Confirm that the starting address, number, and data of the captured raw bytes match the command.

Try it yourself

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

📖 Manual Source

  • HIWIN Robot Communication User Manual (C25UE801-2211)
  • Ch.5.5 MODBUS Transmission Status Monitoring — p.117 (Monitor overview, packet structure, 200-lines/hex)
    • Ch.5.5.1 Monitoring Function Interface Setting — p.118~119 (HRSS / Caterpillar: Expert→Display→Fieldbus→Modbus Monitor, Show message window, Channel/Server, Send, USB Export)
  • Ch.5.5.2 Transmission Status Message Specifications — p.120~127 (Table 57 Transmission and reception format, 5.5.2.1 Connect/Disconnect, 02h/01h/0Fh/04h/03h/10h, hand calculation example)
  • Function Code Cheatsheet: Appendix A · Word/32bit conversion: Appendix B
  • Shared simulator: _shared/robot_server_sim.py · Master CLI: _shared/modbus_master.py

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_monitor/main.pyPython
"""08. Monitoring & Debugging — Modbus frame damage control (modbus_monitor)

This script is written in pure Python so that it can be automatically verified without hardware or Wireshark.
It is a tool that interprets the content (Function Code + Data) of Modbus packets so that humans can read them.
in hexadecimal on the HRSS/Caterpillar Modbus Monitor screen or in the Wireshark detail tree.
If you input the visible frame as is, the starting address, number, number of bytes, and data are interpreted.

Verify the transmission/reception format and three hand calculation examples in Manual Ch.5.5.2.
Only standard libraries are used and no ports/equipment are required.

Run: python src/modbus_monitor/main.py"""
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


#Function Code Name Tag (Manual 5.5.2)
FC_NAMES = {
    0x02: "Read Discrete Inputs",
    0x01: "Read Coils",
    0x0F: "Write Multiple Coils",
    0x04: "Read Input Registers",
    0x03: "Read Holding Registers",
    0x10: "Write Multiple Registers",
    0x06: "Write Single Register",
}

#Data type: Is the read response Bit (status byte) or Word (register)?
BIT_FC = {0x01, 0x02, 0x0F}     # Coil / Discrete Input
WORD_FC = {0x03, 0x04, 0x10, 0x06}  # Holding / Input Register


def parse_hex(text):
    """A hexadecimal string like '02 01 2C 00 05' or '02012C0005' as a byte list."""
    cleaned = text.replace("0x", "").replace(",", " ")
    parts = cleaned.split()
    if len(parts) == 1 and len(parts[0]) % 2 == 0:
        #If it is attached without a space, it is read by cutting off 2 characters.
        s = parts[0]
        parts = [s[i:i + 2] for i in range(0, len(s), 2)]
    return [int(p, 16) for p in parts]


def _u16(hi, lo):
    """The 2 upper/lower bytes as an unsigned 16-bit integer."""
    return (hi << 8) | lo


def decode_frame(text, kind="auto"):
    """Interprets the Modbus frame (Function Code + Data, hexadecimal string) and returns it as a dict.

    kind:
      - "request": interpreted as Master transmission (request)
      - "response": interpreted as a slave response
      - "auto": Estimating request/response based on length and structure (for practical verification)

    Key keys of the returned dict:
      fc, fc_name, role("request"/"response"), and FC-specific fields
      (start, count, byte_count, values, bits ...) + lines (list of human description lines)"""
    data = parse_hex(text)
    if not data:
        raise ValueError("Empty frame.")

    fc = data[0]
    fc_name = FC_NAMES.get(fc, f"Unknown(0x{fc:02X})")
    body = data[1:]
    out = {"fc": fc, "fc_name": fc_name, "raw": data}
    lines = [f"FC=0x{fc:02X} ({fc_name})"]

    #--- Error response: FC most significant bit is 1 (e.g. 0x82, 0x83) ---
    if fc & 0x80:
        base = fc & 0x7F
        exc = body[0] if body else None
        exc_name = {
            0x01: "Illegal Function",
            0x02: "Illegal Data Address",
            0x03: "Illegal Data Value",
        }.get(exc, "?")
        out.update(role="exception", base_fc=base, exception=exc)
        lines = [
            f"Error response! Original FC=0x{base:02X},"
            f"Exception Code=0x{exc:02X} ({exc_name})"
        ]
        out["lines"] = lines
        return out

    role = _guess_role(fc, body) if kind == "auto" else kind
    out["role"] = role

    if fc in BIT_FC or fc in WORD_FC:
        if role == "request":
            _decode_request(fc, body, out, lines)
        else:
            _decode_response(fc, body, out, lines)
    else:
        lines.append(f"Not supported FC, raw={_hexs(body)}")

    out["lines"] = lines
    return out


def _guess_role(fc, body):
    """Request/response automatic estimation. Write (0F/10) is longer because more bytes are added to the transmission."""
    if fc in (0x0F, 0x10):
        #Request: Start (2) + Number (2) + Number of bytes (1) + Data (>=2) -> 7 bytes or more
        #Response: start(2)+count(2) -> exactly 4 bytes
        return "response" if len(body) == 4 else "request"
    if fc == 0x06:
        return "request"  #06h has the same request/response format (address 2 + value 2)
    #Read (01/02/03/04): Request is start (2) + number (2) = 4 bytes, response is byte number (1) + data
    return "request" if len(body) == 4 else "response"


def _decode_request(fc, body, out, lines):
    start = _u16(body[0], body[1])
    count = _u16(body[2], body[3])
    out.update(role="request", start=start, count=count)
    lines.append(f"Start address = 0x{start:04X} = {start}")
    lines.append(f"count = {count}")

    if fc in (0x0F, 0x10):  #Multi-write: even data is loaded
        byte_count = body[4]
        payload = body[5:5 + byte_count]
        out["byte_count"] = byte_count
        lines.append(f"number of bytes = {byte_count}")
        if fc == 0x10:  # Word
            values = [_u16(payload[i], payload[i + 1])
                      for i in range(0, len(payload), 2)]
            out["values"] = values
            lines.append(f"values (decimal) = {values}")
        else:           # 0Fh Bit
            bits = _bytes_to_bits(payload, count)
            out["bits"] = bits
            lines.append(f"bits = {bits}")


def _decode_response(fc, body, out, lines):
    if fc in (0x0F, 0x10):  #Write response: start address + number echo
        start = _u16(body[0], body[1])
        count = _u16(body[2], body[3])
        out.update(role="response", start=start, count=count, echo=True)
        lines.append(f"Start address (echo) = 0x{start:04X} = {start}")
        lines.append(f"Count(Echo) = {count}")
        return

    if fc == 0x06:  #Single write response: echo address+value
        addr = _u16(body[0], body[1])
        val = _u16(body[2], body[3])
        out.update(role="response", start=addr, values=[val], echo=True)
        lines.append(f"Address(echo) = 0x{addr:04X} = {addr}")
        lines.append(f"value(echo) = {val}")
        return

    #Read response: number of bytes (1) + data
    byte_count = body[0]
    payload = body[1:1 + byte_count]
    out["byte_count"] = byte_count
    lines.append(f"number of bytes = {byte_count}")
    if fc in WORD_FC:  #03/04: Register values
        values = [_u16(payload[i], payload[i + 1])
                  for i in range(0, len(payload), 2)]
        out["values"] = values
        lines.append(f"values (decimal) = {values}")
    else:              #01/02: Bit status
        bits = _bytes_to_bits(payload, None)
        out["bits"] = bits
        lines.append(f"bits = {bits}")


def _bytes_to_bits(payload, count):
    """Status bytes as LSB-first bit list. If count is given, cut by that amount."""
    bits = []
    for b in payload:
        for i in range(8):
            bits.append((b >> i) & 1)
    return bits[:count] if count is not None else bits


def encode_write_multiple(start, values):
    """Reverse: Start address + value list -> 10h (Write Multiple Registers) request frame hex.

    Same logic as homework_02 (placed here for reference)."""
    byte_count = len(values) * 2
    frame = [0x10, (start >> 8) & 0xFF, start & 0xFF,
             (len(values) >> 8) & 0xFF, len(values) & 0xFF, byte_count]
    for v in values:
        v &= 0xFFFF
        frame += [(v >> 8) & 0xFF, v & 0xFF]
    return " ".join(f"{b:02X}" for b in frame)


def _hexs(bs):
    return " ".join(f"{b:02X}" for b in bs)


def _show(title, text, kind="auto"):
    print(f"--- {title} ---")
    print(f"Input frame: {text}")
    res = decode_frame(text, kind)
    for ln in res["lines"]:
        print(f"  {ln}")
    return res


def main():
    print("=== Modbus frame damage ===")

    #① Request 02h — Read Discrete Inputs
    r1 = _show("① Request 02h", "02 01 2C 00 05", "request")
    assert r1["fc"] == 0x02 and r1["start"] == 300 and r1["count"] == 5, r1

    print()
    #② Response 04h — Read Input Registers
    r2 = _show("② Response 04h", "04 04 00 5A 00 00", "response")
    assert r2["fc"] == 0x04 and r2["byte_count"] == 4 and r2["values"] == [90, 0], r2

    print()
    #③ Request 10h — Write Multiple Registers
    r3 = _show("③ Request 10h", "10 00 C9 00 03 06 00 6F 00 DE 01 4D", "request")
    assert r3["fc"] == 0x10 and r3["start"] == 201 and r3["count"] == 3, r3
    assert r3["values"] == [111, 222, 333], r3

    print("-" * 30)
    print("Frame decoding verification passed ✅")
    return 0


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

Assignment

Everything is solved with pure Python logic without hardware, ports, or Wireshark. Uses only standard libraries.

Problem 1 — Loss of a given hex frame

  • File name: homework_01/main.py
  • Core concepts: Modbus frame structure [Function Code][Data], read Request/Response interpretation, number of bytes = number × 2

Requirements

  • Decode the next two frames (hexadecimal) so that they can be read by humans.
  1. Request 04 01 90 00 02 — Output Function Code, starting address (decimal), and number.
  2. Response 03 06 00 64 00 00 04 4E — Outputs Function Code, number of bytes, and register value list (decimal).
  • Compare each value with the hand calculation result (assert), and if correct, Frame decoding completed ✅ in the last line.

Expected output

[1] Request FC=0x04, start address=400(0x0190), number=2
[2] Response FC=0x03, number of bytes=6, value=[100, 0, 1102]
Frame decoding completed ✅

Hint

  • The 16bit value combines the upper and lower bytes: (hi << 8) | lo. Modbus is big-endian (parent first).
  • The second byte of the response is number of bytes. Word reading is Number of bytes = Number × 2 → byte 6 is 3 registers.
  • 0x0190 = 400, 0x044E = 1102. Calculate it by hand and match it with the code.

Problem 2 — Encoding a list of values into a 10h request frame (reverse)

  • File name: homework_02/main.py
  • Key concepts: Opposite direction of loss poison (encode), 10h Write Multiple Registers transmission format, round-trip verification

Requirements

  • Make the starting address 201 and the value [111, 222, 333] into 10h (Write Multiple Registers) Master transmission frame (hexadecimal).
  • Frame structure: [10][Starting address (2)][Number (2)][Number of bytes (1)][Values (Number × 2)].
  • Check whether the created frame is the same as the manual example 10 00 C9 00 03 06 00 6F 00 DE 01 4D, and decode again to verify that the original (starting address, value) is restored (round trip).

Expected output

Input: starting address=201, value=[111, 222, 333]
10h request frame: 10 00 C9 00 03 06 00 6F 00 DE 01 4D
Rereading: starting address = 201, count = 3, value = [111, 222, 333]
Encoding/Round-Trip verification completed ✅

Hint

  • Don't forget the Number of bytes = Number × 2 column. 10h transmission has number of bytes + data, but response echo (10 00 C9 00 03) does not.
  • Each value is decomposed into 2 bytes: upper (v >> 8) & 0xFF, lower v & 0xFF. 0x006F=111, 0x00DE=222, 0x014D=333.
  • First truncate to v & 0xFFFF to prevent values exceeding 16 bits from being leaked.

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 — given hex frame loss

Key points:
- Modbus frame has [Function Code][Data] structure. The first byte is FC.
- Read response (03/04) is [number of bytes (1)][register value...]. Number of bytes = Number × 2.
- The word value is made by combining the upper and lower bytes into a 16-bit integer: (hi<<8)|lo.
- 03h response `03 06 00 64 00 00 04 4E` => Number of bytes 6 => 3 registers => [100, 0, 1102].

Common mistakes:
- Confusing the number of bytes (=number × 2) and the number of registers. byte 6 is 3 registers.
- Reversing the High/Low byte order and combining them incorrectly, such as 0x6400 (=25600). Modbus is big-endian (parent first)."""
import os
import sys

#_shared navigation bootstrap (any 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


def parse_hex(text):
    return [int(p, 16) for p in text.split()]


def u16(hi, lo):
    return (hi << 8) | lo


def decode_read_response(text):
    """Read response (03h/04h): Interpret [FC][number of bytes][register value...]."""
    b = parse_hex(text)
    fc, byte_count = b[0], b[1]
    payload = b[2:2 + byte_count]
    values = [u16(payload[i], payload[i + 1]) for i in range(0, byte_count, 2)]
    return fc, byte_count, values


def decode_request(text):
    """Read request (02h/03h/04h): Interpret [FC][start address (2)][number (2)]."""
    b = parse_hex(text)
    fc = b[0]
    start = u16(b[1], b[2])
    count = u16(b[3], b[4])
    return fc, start, count


def main():
    #Frame 1 — Request 04h (Read Input Registers)
    fc, start, count = decode_request("04 01 90 00 02")
    print(f"[1] Request FC=0x{fc:02X}, start address={start}(0x{start:04X}), number={count}")
    assert (fc, start, count) == (0x04, 400, 2)

    #Frame 2 — Response 03h (Read Holding Registers), number of bytes 6 => 3 registers
    fc, bc, values = decode_read_response("03 06 00 64 00 00 04 4E")
    print(f"[2] Response FC=0x{fc:02X}, number of bytes={bc}, value={values}")
    assert (fc, bc, values) == (0x03, 6, [100, 0, 1102])

    print("Frame decoding completed ✅")


if __name__ == "__main__":
    main()
homework_02/main.pyPython
"""Answer 2 — Encode the list of values into a 10h (Write Multiple Registers) request frame (reverse)

Key points:
- The opposite direction of decode. Create a Master transmission frame with the starting address and value list.
- 10h request = [FC=10][start address (2)][number (2)][number of bytes (1)][values ​​(number×2)].
- Number of bytes = Number × 2 (Word rule). Each value is decomposed into two big-endian bytes: (v>>8), (v&0xFF).
- When the created frame is decoded again, the original (starting address, value) must be restored as is (round-trip verification).

Common mistakes:
- Omitting the number of bytes field (must be present in 10h transmission, not in response echo).
- If a value exceeding 16 bits is entered without & 0xFFFF, it leaks as 3 bytes."""
import os
import sys

#_shared navigation bootstrap (any 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


def encode_write_multiple(start, values):
    """Start address + value list -> 10h request frame hex string."""
    count = len(values)
    byte_count = count * 2
    frame = [0x10, (start >> 8) & 0xFF, start & 0xFF,
             (count >> 8) & 0xFF, count & 0xFF, byte_count]
    for v in values:
        v &= 0xFFFF  #Safely cut to 16 bit
        frame += [(v >> 8) & 0xFF, v & 0xFF]
    return " ".join(f"{b:02X}" for b in frame)


def decode_write_multiple(text):
    """For round-trip verification: 10h request frame -> (start address, value list)."""
    b = [int(p, 16) for p in text.split()]
    start = (b[1] << 8) | b[2]
    count = (b[3] << 8) | b[4]
    payload = b[6:6 + b[5]]
    values = [(payload[i] << 8) | payload[i + 1] for i in range(0, len(payload), 2)]
    return start, count, values


def main():
    start, values = 201, [111, 222, 333]
    frame = encode_write_multiple(start, values)
    print(f"Input: start address={start}, value={values}")
    print(f"10h request frame: {frame}")
    #Expectation: 10 00 C9 00 03 06 00 6F 00 DE 01 4D
    assert frame == "10 00 C9 00 03 06 00 6F 00 DE 01 4D", frame

    #Round-trip verification: Decode again to get the original value.
    d_start, d_count, d_values = decode_write_multiple(frame)
    print(f"Reread: start address={d_start}, number={d_count}, value={d_values}")
    assert (d_start, d_count, d_values) == (201, 3, [111, 222, 333])

    print("Encoding/round-trip verification completed ✅")


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