Connect the robot and the vision (higher level) system with a single LAN cable, and create a flow where the robot sends “Take a picture” ({TRIG}) and the vision replies “Here are the coordinates” ({X,Y,R}). The key is to manually confirm that the COPEN/CWRITE/CREAD/CCLEAR commands of the actual device HRSS correspond 1:1 to the standard socket code of the PC.
Learning Objectives
Explain the same IP domain condition (matching the first 3 octets) that establishes TCP/IP and match the Robot/Vision IP.
Create the transmission and reception flow of 4 commands COPEN(ETH,HANDLE)/CWRITE/CREAD/CCLEAR.
Create a standard socket client (Robot Client) on your PC to actually communicate with the vision server.
Target environment
Tools: Python 3.10+ (only uses standard socket module — no additional installation required)
Platform: Windows PC (simulation) — Replace actual equipment with verified vision server _shared/tcp_echo_server.py
Verification: Server on terminal A, python src/tcp_vision/main.py on terminal B
Key concepts
1) COPEN / CWRITE / CREAD / CCLEAR — If the first argument is ETH, TCP/IP
In HRSS, communication ends with the four commands below. If the first argument of COPEN is ETH, it is TCP/IP, and if it is SER, it is RS-232. Just one letter difference completely changes the type of channel. (SER is covered in 04. RS-232.)
command
Format
Role
COPEN
COPEN(ETH, HANDLE)
Open the channel. First argument ETH=TCP/IP, SER=RS-232. HANDLE is the channel identifier for subsequent commands
CWRITE
CWRITE(HANDLE, SendData1, SendData2)
Send data to channel (sendall on PC)
CREAD
CREAD(HANDLE, receiveData1, receiveData2)
Receive data from channel (recv on PC)
CCLEAR
CCLEAR(HANDLE)
Clear the channel buffer (removing old residual data)
⚠️ TCP is COPEN(ETH, ...), serial is COPEN(SER, ...). This episode is all about ETH.
2) TCP/IP-is Must be “in the same neighborhood” to work — Same IP domain
Data only travels to and from the same IP network domain. That is, the first 3 octets of the IP address must match.
device
IP example
Is communication possible?
robot
192.168.1.10
—
Vision (top)
192.168.1.20
✅ Matches the first 3 octets 192.168.1
Vision (top)
192.168.2.20
❌ The third octet is different
The connection is serial connection with an Ethernet cable, and if there are multiple upper systems, they are bundled into HUB. If there is a camera, power is supplied via a LAN cable using PoE (Power over Ethernet).
When formatting is turned off, ASCII decimal numbers are interpreted one byte at a time without the Start/End/Division symbol. A → 65
The parent system always receives it in {data} format. Our PC servers also follow this protocol. Before testing, you must check the "Show message" option in the network settings to see the communication content in the dialog window (default is off).
4) Who is the client and who is the server?
Role
device
Action
Client
🤖 Robot
Connect first (COPEN) and send request
Server
💻 Vision (higher level) system
Open a port and wait for a response
In the lab, a PC (tcp_echo_server.py) replaces the vision server, and we write the Robot Client in Python.
See as example
Example — src/tcp_vision/main.py: Robot Client (Vision Client)
Send {TRIG} to the vision server and receive and parse {X,Y,R}. (see file for full code)
#Core — COPEN (ETH) → CWRITE → CREAD flow
def send_packet(sock, payload):
sock.sendall(("{" + payload + "}").encode("ascii")) # CWRITE(HANDLE, payload)
def recv_packet(sock):
data = sock.recv(1024).decode("ascii") # CREAD(HANDLE, ...)
return data.strip().strip("{}").split(",") #After removing { }, parsing with
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((args.host, args.port)) # COPEN(ETH, HANDLE)
send_packet(s, "TRIG")
x, y, r = recv_packet(s)
print(f"Parsing result -> X={x}, Y={y}, rotation angle={r}")
#When leaving the with block, the socket is closed (CCLEAR + acts as a channel termination)
🤖 The Python flow you just wrote corresponds 1:1 with the actual equipment HRSS code.
steps
💻 PC (Python)
🤖 Real equipment (HRSS)
Open Channel
s.connect((host, port))
COPEN(ETH, HANDLE)
Sending a filming request
s.sendall("{TRIG}")
CWRITE(HANDLE, "TRIG")
Receive coordinates
s.recv(1024)
CREAD(HANDLE, recv1, recv2)
Buffer Cleanup
(end with block)
CCLEAR(HANDLE)
Since HRSS automatically processes { } wrapping and , distinction, you only need to worry about payload (TRIG, coordinate value) in actual equipment.
View Run/verify
Terminal A — Launches a verified vision server.
cd lecture/03_network/03_tcpip
python ../../_shared/tcp_echo_server.py --mode vision --port 6000
Behavior of vision mode (fixed simulation):
Sent by robot
server responds
Meaning (X, Y, rotation angle)
{TRIG}
{123.45,67.89,30.0}
Part 1 coordinates
{TRIG2}
{-45.10,210.00,-15.0}
Part 2 coordinates
Other
{0.00,0.00,0.0}
No detection
Terminal B — Runs the robot client. (Leave terminal A as is)
cd lecture/03_network/03_tcpip
python src/tcp_vision/main.py
At this time, the following is displayed on terminal A (server):
[Connection] Connect to ('127.0.0.1', xxxxx)
To: {TRIG}
Sent: {123.45,67.89,30.0}
✅ Checkpoint: If X=123.45, Y=67.89, rotation angle=30.0 is output on terminal B, the entire process of sending ({TRIG}) → receiving → parsing has been successful.
Common Mistakes
Q. ConnectionRefusedError appears.
A. The server (terminal A) does not appear or the port is different. Launch the server first, and set the --port of the server and client to be the same (6000).
Q. 🤖 Communication itself does not work on the actual equipment.
A. The most common case is that the IP first 3 octets are mismatched. Make sure that even the third octet of the robot/vision IP is the same (e.g. 192.168.1.xx for both). Also check that the first argument of COPEN is ETH (the serial is SER).
Q. There is no response and it stops working.
A. The end-of-packet symbol } is missing. The server processes packets in units of }, so make sure to surround the transmission string with { ... }.
Q. The coordinates always come as 0.00,0.00,0.0.
A. The server received an unknown request. Send payload to exactly TRIG or TRIG2. (If the value appears broken, check whether “Show message” is unchecked or non-format)
Cleanup
I understand TCP/IP establishment conditions (same IP domain, first 3 octets match) and connection structure (HUB, PoE).
I learned the four commands COPEN(ETH,…)/CWRITE/CREAD/CCLEAR and the { }·, packet protocol.
I launched the vision server on my PC and succeeded in actually transmitting, receiving and parsing with Robot Client, which I wrote myself.
The framework of the actual equipment program has been secured through 1:1 correspondence between PC code and HRSS code.
Try it yourself
Solve the assignment below. The reference solution is shown below.
📖 Manual Source
HIWIN Robot Communication User Manual (C25UE801-2211)
Ch.3 Ethernet TCP/IP communication method (p.30~33)
Same IP domain / HUB / PoE, IP setting path, packet format ({ }·,·Show message), COPEN/CWRITE/CREAD/CCLEAR and Client(Robot)–Server(Vision) structure
Practice code: _shared/tcp_echo_server.py (Verified operation)
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/tcp_vision/main.pyPython
"""03. Ethernet TCP/IP — Robot Client (vision client) (tcp_vision)
As a robot (client), connect to the vision (higher level) server and send {TRIG},
Receives and parses {X,Y,R} packets returned by the server and outputs them to the console.
HRSS communication protocol:
- Packets are automatically wrapped with curly braces { } at the beginning/end -> TRIG transmission = actual {TRIG} transmission
- Values are separated by commas -> 123.45,67.89,30.0
First, launch the verified vision server in a separate terminal:
python ../../_shared/tcp_echo_server.py --mode vision --port 6000
Run:
python src/tcp_vision/main.py
python src/tcp_vision/main.py --host 127.0.0.1 --port 6000"""
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
import socket
def send_packet(sock: socket.socket, payload: str) -> None:
"""It is sent wrapped in { } according to the HRSS protocol (CWRITE role)."""
msg = "{" + payload + "}"
sock.sendall(msg.encode("ascii")) # CWRITE(HANDLE, payload)
print("Send to:", msg)
def recv_packet(sock: socket.socket) -> list:
"""Receive the server response, remove { } and separate into , (CREAD role)."""
data = sock.recv(1024).decode("ascii") # CREAD(HANDLE, ...)
body = data.strip().strip("{}") #remove { }
print("Receiving:", data.strip())
return body.split(",") #, parsed as
def main():
ap = argparse.ArgumentParser(description="Robot Client — TCP vision client")
ap.add_argument("--host", default="127.0.0.1",
help="Vision server IP (if it is actual equipment, the first 3 octets match the robot)")
ap.add_argument("--port", type=int, default=6000, help="Vision server port")
args = ap.parse_args()
print(f"=== TCP Vision Client === {args.host}:{args.port}")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((args.host, args.port)) # COPEN(ETH, HANDLE)
send_packet(s, "TRIG") #Request to film
x, y, r = recv_packet(s) #Coordinate reception/parsing
print(f"Parsing result -> X={x}, Y={y}, rotation angle={r}")
#When leaving the with block, the socket is closed (CCLEAR + acts as a channel termination)
if __name__ == "__main__":
main()
Assignment
Both tasks require a proven vision server to be running. First, in a separate terminal:
You must first remove the curly braces with body = data.strip().strip("{}") to avoid breaking the first/last value.
Instead of comparing strings, a negative number is determined as < 0 after conversion to float().
Problem 2 — average/count output after looping multiple shot requests
File name: homework_02/main.py
Core concept: repeating CWRITE/CREAD over one connection, aggregating statistics
Requirements
Open one socket connection and repeatedly send and receive {TRIG}--count times (default 3 times).
Parse each response and output Detection/Detection absence ({0.00,0.00,0.0}).
Finally, the number of detections and the average X and Y of the detected coordinates are output. (No detections are excluded from the average)
Expected output
[1/3] X=123.45, Y=67.89, R=30.0 (detection)
[2/3] X=123.45, Y=67.89, R=30.0 (detection)
[3/3] X=123.45, Y=67.89, R=30.0 (detection)
------------------------------
3 detections out of 3 requests
Average coordinates -> X=123.45, Y=67.89
Hint
Do not open a new socket for each request, but only repeat sending and receiving using the for loop within the with socket(...) block.
Detection judgment: not (x == 0.0 and y == 0.0 and r == 0.0).
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 — TRIG2 Negative coordinate processing and display
Key points:
- When you send {TRIG2}, the server gives you coordinates mixed with negative numbers, such as {-45.10,210.00,-15.0}.
- Since the parsing result is a string, it must be converted to float() to calculate sign and decimal.
- Negative coordinates are displayed clearly in sign (+/-) alignment.
Common mistakes:
- Split without removing { } so that curly brackets are added to the first and last values.
- To compare/calculate strings as is without converting to float."""
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
import socket
def trig(sock: socket.socket, payload: str):
"""Wrap the payload in { } and send it, strip the response and return it as a (x, y, r) float."""
sock.sendall(("{" + payload + "}").encode("ascii")) # CWRITE
data = sock.recv(1024).decode("ascii").strip() # CREAD
body = data.strip("{}")
x, y, r = (float(v) for v in body.split(","))
return data, x, y, r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=6000)
args = ap.parse_args()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((args.host, args.port)) # COPEN(ETH, HANDLE)
raw, x, y, r = trig(s, "TRIG2") #Part 2 request
print(f"Send: {{TRIG2}}")
print(f"Receiving: {raw}")
sign = "Includes negative coordinates" if (x < 0 or y < 0 or r < 0) else "all positive numbers"
print(f"Parsing result -> X={x:+.2f}, Y={y:+.2f}, rotation angle={r:+.1f} ({sign})")
if __name__ == "__main__":
main()
homework_02/main.pyPython
"""Answer 2 — Loop through multiple shot requests and output average/count
Key points:
- {TRIG} is repeatedly sent and received N times on the same connection (socket) (CWRITE/CREAD repetition).
- Accumulate X and Y of each response and calculate the number of detections and average at the end.
- {0.00,0.00,0.0} (no detection) must be excluded from the average to make it a meaningful statistic.
Common mistakes:
- Operating inefficiently by opening and closing a new socket for each request (one connection is sufficient).
- Even the no detection (0,0,0) response is mixed with the average to reduce the value."""
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
import socket
def trig(sock: socket.socket, payload: str):
"""Wrap the payload in { } and send it, strip the response and return it as a (x, y, r) float."""
sock.sendall(("{" + payload + "}").encode("ascii")) # CWRITE
data = sock.recv(1024).decode("ascii").strip() # CREAD
x, y, r = (float(v) for v in data.strip("{}").split(","))
return x, y, r
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=6000)
ap.add_argument("--count", type=int, default=3, help="Number of repetitions of shooting requests")
args = ap.parse_args()
xs, ys = [], []
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.connect((args.host, args.port)) # COPEN(ETH, HANDLE)
for i in range(1, args.count + 1):
x, y, r = trig(s, "TRIG") #Request for filming every episode
detected = not (x == 0.0 and y == 0.0 and r == 0.0)
mark = "detection" if detected else "No detection"
print(f"[{i}/{args.count}] X={x:.2f}, Y={y:.2f}, R={r:.1f} ({mark})")
if detected:
xs.append(x)
ys.append(y)
print("-" * 30)
n = len(xs)
print(f"Detected {n} times out of {args.count} requests")
if n:
print(f"Average coordinate -> X={sum(xs) / n:.2f}, Y={sum(ys) / n:.2f}")
else:
print("There are no coordinates detected, so averaging cannot be done.")
if __name__ == "__main__":
main()