RS-232 is the oldest serial communication that provides a 1:1 connection between a robot and a host (PC/PLC) with a single cable. Front page 03. The CWRITE/CREAD and { }·, packet formats learned in [TCP/IP are used as is in serial, and what changes is the physical layer and the first argument of COPEN (ETH → Check by hand that it is only SER). Since the virtual COM port is not in the automatic verification environment, the core packet framing is reproduced in pure Python without a port, and actual serial transmission and reception is directly practiced using com0com.
Learning Objectives
Describes the physical characteristics of RS-232 (1:1 point-to-point, TX↔RX cross wiring + common GND, same baud rate on both sides).
Know that the first argument of COPEN(SER, HANDLE) is SER, which means serial, and distinguish it from TCP/IP ETH.
Create encode/decode of the packet wrapped in { } and separated by ,, and compare it with non-format (ASCII decimal).
Create a com0com virtual COM port pair (COM5↔COM6) to operate the pyserial echo server/client.
Target environment
Tools: Python 3.10+ / pyserial 3.5
Virtual hardware: com0com (open source virtual COM port driver) — No physical cables or robots required
Verification: CLI automatic execution — python src/serial_comm/main.py (port not required)
Key concepts
1) RS-232 is a 1:1 point-to-point connection that connects only two devices.
Instead of multiple nodes sharing a single wire like Ethernet, two devices are directly connected via one cable. The key to wiring is TX↔RX cross. Connect the robot's transmitting pin (TX) to the host's receiving pin (RX), the robot's receiving pin (RX) to the host's transmitting pin (TX), and connect common ground (GND) on both sides.
The baud rate must be the same on both sides. There is no auto-negotiation like Ethernet, so if one side has 9600 and the other side has 19200, the characters will be garbled or communication will not be possible — you have to set them manually.
2) Command — TCP/IP-and Almost identical, only the first argument is different
COPEN → CWRITE → CREAD flow is 03. TCP/IP. Just one thing, only the first argument of COPEN changes from ETH to SER.
COPEN(SER, HANDLE) ; Open serial port (TCP/IP-is ETH)
CWRITE(HANDLE, SendData1, SendData2) ; Send — Same as TCP/IP-and
CREAD(HANDLE, receiveData1, receiveData2) ; Received — TCP/IP-and Same
3) Packet format — TCP/IP-and common
Item
Rules
Example
Start/End symbol
Wrap the front and back of the packet with curly brackets {}
123,456 → {123,456}
separator
Separate multiple data with comma ,
{123,456}
non-format
If format is turned off, data is interpreted one byte at a time as ASCII CODE (decimal) without Start/End·delimiter
"AB" → [65, 66]
Show message
Communication details are displayed in a dialog box on the screen. Be sure to check before testing (default not displayed)
—
4) TCP/IP (Part 03) vs RS-232 comparison at a glance
Category
TCP/IP (Part 03)
RS-232 (this episode)
COPEN first argument
ETH
SER
physical layer
Ethernet (LAN, RJ45)
Serial cable (D-sub 9 pin)
Connection method
Many-to-many/network (IP/Port)
1:1 point-to-point
wiring core
via hub/switch
TX↔RX Cross + Common GND
Distance/Speed
Long and fast (tens to hundreds of Mbps)
Short and slow (approximately within 15 m, 9600~115200 bps)
speed negotiation
automatic
Manual (same baud setting on both sides)
packet format
{ }, ,, non-format
Same
Key point: The command and packet format are the same, only the physical layer and the first argument of COPEN are different.
Environment preparation
#1) Install pyserial (first time) — Required for console practice. The packet demo itself uses only the standard library.
python -m pip install pyserial
#2) Prevent console Hangul from being broken (every session)
set PYTHONUTF8=1 # PowerShell: $env:PYTHONUTF8=1
Create a com0com virtual COM port pair (COM5↔COM6)
💻 PC Simulation — Create virtual port pairs that write to one and read from the other, without physical cables. This is a software replacement for the RS-232 cross cable. (Required only for console serial practice, not required for running src/serial_comm/main.py.)
com0com installation: Download and run the com0com (open source) installer. If an unsigned driver warning appears, allow installation.
Create a port pair: Open "Setup Command Prompt (com0com)" from the Start menu or "Setup" from the GUI. Rename the default CNCA0 ↔ CNCB0 pair to COM5 ↔ COM6.
GUI: Enter COM5 and COM6 in the port name fields of the two items, respectively, and then Apply.
Command window: change CNCA0 PortName=COM5 followed by change CNCB0 PortName=COM6.
Confirm: Device Manager → If you see two ports COM5 and COM6 under "Ports (COM & LPT)", it is successful.
💡 If COM5/COM6 is already used, just use another empty number (e.g. COM7/COM8) and change the port number in the subsequent command.
See as example
Example — src/serial_comm/main.py: Packet framing demo (no port required)
encode/decode in RS-232 packet format is implemented in pure Python and round-trip verified with example values. (see file for full code)
#Core part — wrapped in { } and separated by (format used by CWRITE/CREAD)
def encode_packet(values):
return "{" + ",".join(str(v) for v in values) + "}" # [123,456] -> "{123,456}"
def decode_packet(packet):
body = packet.strip().strip("{}") #Remove { } before and after
return [int(tok) for tok in body.split(",")] # "{123,456}" -> [123,456]
#non-format — 1 byte of ASCII CODE (decimal number) without start/end/delimiter
encode_nonformat = lambda text: [ord(ch) for ch in text] # "AB" -> [65, 66]
🤖 The above Python flow corresponds 1:1 with the actual equipment HRSS code. Part 03 In-TCP/IP Compared to this table, only ETH is changed to SER.
steps
💻 PC (Python)
🤖 Actual equipment (HRSS RS-232)
open port
serial.Serial('COM5', 9600, timeout=1)
COPEN(SER, HANDLE)
send
ser.write(encode_packet([123,456]))
CWRITE(HANDLE, SendData1, ...)
reception
decode_packet(ser.read(64))
CREAD(HANDLE, receiveData1, ...)
Close
ser.close()
CCLOSE(HANDLE)
Presets
Both --baud 9600 are the same
In Startup Settings → RS-232, set the baud to the same as the host, check "Show message"
💻 com0com Hardware Lab (Optional) — Sending and Receiving Real-World Serial
Once you've created a virtual port pair, you can practice real serial round trips with shared code.
Terminal A — Echo Server (host role, COM6). Returns the received data by wrapping it in { }.
Success is achieved when the client terminal displays Receive b'{{123}}' (the echo server wraps once more with { }). --baud on both sides must be the same, and if you change only one side to 19200, the response will be broken or return an empty value (b'') — exactly the same symptoms as a real baud mismatch.
View Run/verify
cd lecture/03_network/04_rs232
python src/serial_comm/main.py
Expected output:
=== RS-232 packet framing demo (no hardware required) ===
[1] format mode (same as TCP/IP-and)
value [123, 456] -> encode "{123,456}" -> decode [123, 456] OK
Value [90000, -45100, 30] -> "{90000,-45100,30}" -> [90000, -45100, 30] OK
[2] non-format mode (ASCII decimal)
String "AB" -> Code [65, 66] -> Restore "AB" OK
------------------------------
Packet framing round-trip verification passed ✅
✅ Checkpoint: If the last line is Packet framing round-trip verification passed ✅ and the exit code is 0, then the packet format encode/decode was round-tripped correctly.
Common Mistakes
Q. SerialException: could not open port 'COM6'
A. The com0com pair has not been created, there is a typo in the port number, or another program has occupied the port. Check COM5/COM6 in Device Manager (checkpoint) and close other serial programs.
Q. The response comes as b'' (empty value).
A. The echo server does not appear or there is a baud mismatch. Launch the Echo Server (COM6) first, and set both --baud to the same (9600).
Q. The text is broken.
A. baud rate mismatch (or data bit/parity mismatch). Keep the baud on both sides the same and keep the default 8N1. RS-232 does not have auto-negotiation and must be adjusted manually.
Q. I open the same port twice and PermissionError appears.
A. One port (COM6) must be opened only by the echo server, and the opposite port (COM5) must be opened only by the client. Two programs cannot open the same port at the same time.
Q. Nothing is visible on the HRSS communication screen.
A. This is a case where "Show message" is unchecked in the RS-232 settings. Check and test (default value is not displayed).
Cleanup
RS-232 is 1:1 point-to-point serial communication, and the core of the wiring is TX↔RX crossing + common GND, and the same baud rate on both sides.
The command flow (COPEN → CWRITE → CREAD) and packet format ({ }, ,, non-format) are the same as TCP/IP-and, and only the first argument of COPEN is changed to ETH→SER.
Packet framing was verified without the encode/decode port, and actual serial transmission and reception with com0com can be reproduced on a PC.
Try it yourself
Solve the assignment below. The reference solution is shown below.
📖 Manual Source
HIWIN Robot Communication User Manual (C25UE801-2211)
Ch.4 RS-232 Serial Communication — p.34~36 (wiring/cross-wiring, baud rate setting, HRSS/Caterpillar path, packet format)
Ch.3 TCP/IP — COPEN First argument: TCP/IP=ETH, RS-232=SER
Practice code: _shared/serial_echo.py, _shared/serial_client.py (for com0com virtual COM practice)
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/serial_comm/main.pyPython
"""04. RS-232 serial communication — packet framing demo (serial_comm)
Since the com0com virtual COM port or actual serial cable is not in the automatic verification environment,
This demo reproduces only the RS-232 packet format in pure Python *without hardware*.
Key:
- HIWIN packet format = Start/end are surrounded by curly braces { }, and values are separated by commas.
Example) [123, 456] -> "{123,456}" -> Parsing -> [123, 456]
- non-format (no start/end/delimiter) = Interpret data 1 byte as ASCII CODE (decimal number).
Example) "AB" -> [65, 66] (A=65, B=66)
- This format/command (CWRITE/CREAD) is the same as TCP/IP (Part 03). physical layer
Only the first argument of COPEN (ETH -> SER) changes.
Actual serial transmission and reception practice is com0com + _shared/serial_echo.py + serial_client.py.
Follow the step-by-step instructions in README. This main.py does not need a port.
Run:
python src/serial_comm/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
def encode_packet(values) -> str:
"""Encode the list of integers into a HIWIN format packet string (the shape CWRITE will send).
[123, 456] -> "{123,456}""""
return "{" + ",".join(str(v) for v in values) + "}"
def decode_packet(packet: str) -> list:
"""Decodes the HIWIN format packet string into a list of integers (as received by CREAD).
"{123,456}" -> [123, 456]"""
body = packet.strip().strip("{}") #Remove { } before and after
if not body:
return []
return [int(tok) for tok in body.split(",")]
def encode_nonformat(text: str) -> list:
"""non-format: Encode the string as an ASCII CODE (decimal) list.
"AB" -> [65, 66]"""
return [ord(ch) for ch in text]
def decode_nonformat(codes) -> str:
"""non-format: Decodes the ASCII CODE (decimal number) list into a string.
[65, 66] -> "AB""""
return "".join(chr(c) for c in codes)
def main():
print("=== RS-232 packet framing demo (no hardware required) ===")
#1) format mode — wrapped in { } and separated by (format used by CWRITE/CREAD)
values = [123, 456]
packet = encode_packet(values) #The body of the byte string to be sent by CWRITE
restored = decode_packet(packet) #Result received and parsed by CREAD
ok_fmt = restored == values
print("[1] format mode (same as TCP/IP)")
print(f"values {values} -> encoding \"{packet}\" -> decoding {restored}"
f"{'OK' if ok_fmt else 'FAIL'}")
#Same rule for multiple values — continue with commas
triple = [90000, -45100, 30]
pkt3 = encode_packet(triple)
ok_fmt3 = decode_packet(pkt3) == triple
print(f"value {triple} -> \"{pkt3}\" -> {decode_packet(pkt3)}"
f"{'OK' if ok_fmt3 else 'FAIL'}")
#2) non-format mode — 1 byte of ASCII decimal number without start/end/delimiter
text = "AB"
codes = encode_nonformat(text) #Character -> ASCII CODE
back = decode_nonformat(codes) #ASCII CODE -> Character
ok_nf = back == text
print("[2] non-format mode (ASCII decimal)")
print(f"string \"{text}\" -> code {codes} -> restore \"{back}\""
f"{'OK' if ok_nf else 'FAIL'}")
print("-" * 30)
all_ok = ok_fmt and ok_fmt3 and ok_nf
print("Packet framing round-trip verification passed ✅" if all_ok
else "Packet framing verification failed ❌")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())
Assignment
Both tasks are pure logic, requiring no hardware or ports. You can solve it without com0com or an actual serial cable. Use only standard libraries.
Problem 1 — Integer List Packet Framing/Reverse-Parsing
Write the frame(values) function that turns a list of integers into a packet string in {a,b,c} format.
Write a unframe(packet) function that strips { } from the packet string, divides it by ,, and returns it to a list of integers.
Framing the three cases of [123, 456], [90000, -45100, 30], and [7] → reverse parse, and output as OK/FAIL to see if they are the same as the original list.
Expected output
[123, 456] -> "{123,456}" -> [123, 456] OK
[90000, -45100, 30] -> "{90000,-45100,30}" -> [90000, -45100, 30] OK
[7] -> "{7}" -> [7] OK
Hint
Framing: "{" + ",".join(str(v) for v in values) + "}".
Reverse parsing: First remove the braces with packet.strip().strip("{}"), then convert each token to split(",") and int().
Negative numbers and multi-digit integers are round-tripped according to the same rules.
Problem 2 — non-format(ASCII decimal) Encoding/Decoding
File name: homework_02/main.py
Core concept: non-format (Start/End·no delimiter), character ↔ ASCII CODE (decimal)
Requirements
Write the encode_nonformat(text) function that converts a string into an ASCII CODE (decimal number) list. (Example: "AB" → [65, 66])
Write a decode_nonformat(codes) function that returns the list back to a string.
Encode → decode the three cases of "AB", "123", and "OK", and output them as OK/FAIL to see if they are the same as the original string.
Expected output
"AB" -> [65, 66] -> "AB" OK
"123" -> [49, 50, 51] -> "123" OK
"OK" -> [79, 75] -> "OK" OK
Hint
Encoding is ord (character), decoding is chr (code), 1:1 correspondence.
Note: In non-format, the string "123" is not the integer 123, but the character '1','2','3' = [49, 50, 51] three bytes.
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 — Integer list packet framing/deparsing
Key points:
- The HIWIN format connects values with commas and , and surrounds the entire value with braces { }.
- Reverse parsing first removes { }, then splits it into , and converts each token to int.
- Negative numbers and multi-digit integers also round-trip according to the same rules.
Common mistakes:
- Split without removing { } so that curly braces are added to the first/last token.
- The parsing result is kept as a string and compared == with the original integer list, so it always comes out different."""
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
def frame(values) -> str:
"""Integer list -> "{a,b,c}" packet string (shape to be sent by CWRITE)."""
return "{" + ",".join(str(v) for v in values) + "}"
def unframe(packet: str) -> list:
""""{a,b,c}" packet string -> integer list (results received and parsed by CREAD)."""
body = packet.strip().strip("{}")
if not body:
return []
return [int(tok) for tok in body.split(",")]
def check(values):
packet = frame(values)
restored = unframe(packet)
ok = restored == values
print(f"{values} -> \"{packet}\" -> {restored} {'OK' if ok else 'FAIL'}")
def main():
check([123, 456])
check([90000, -45100, 30])
check([7])
if __name__ == "__main__":
main()
homework_02/main.pyPython
"""Answer 2 — non-format (ASCII decimal) encoding/decoding
Key points:
- Non-format has no start/end ({ }) or delimiter (,) symbols, and each character is ASCII CODE.
(Decimal number) Interpreted as 1 byte. That is, 1 character = 1 integer.
- Encoding is ord (character), decoding is chr (code), and there is a 1:1 correspondence.
- If the format packet looks like "{65,66}", non-format is just the [65, 66] byte string.
Common mistakes:
- Mistaking the number string "65" for the integer 65. '6' and '5' in non-format
Each ord('6')=54 and ord('5')=53 are two bytes.
- Characters outside the ASCII range (0 to 127) are assumed to be 1 byte (only ASCII is dealt with here)."""
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
def encode_nonformat(text: str) -> list:
"""String -> ASCII CODE (decimal) list. "AB" -> [65, 66]."""
return [ord(ch) for ch in text]
def decode_nonformat(codes) -> str:
"""ASCII CODE (decimal) list -> string. [65, 66] -> "AB"."""
return "".join(chr(c) for c in codes)
def check(text: str):
codes = encode_nonformat(text)
restored = decode_nonformat(codes)
ok = restored == text
print(f"\"{text}\" -> {codes} -> \"{restored}\" {'OK' if ok else 'FAIL'}")
def main():
check("AB")
check("123") #Characters '1','2','3' = 49,50,51 (note that it is not the integer 123)
check("OK")
if __name__ == "__main__":
main()