อ่านค่าจากอุปกรณ์ Modbus ที่ไม่รู้ว่าเป็นตัวอะไร พิสูจน์ก่อนตีความ
ก่อนจะถอดความหมายของ register ได้ ต้องรู้ก่อนว่ากำลังคุยกับอะไรอยู่ และคำว่ารู้ในที่นี้หมายถึงเดินไปอ่านป้ายที่ตัวเครื่อง ไม่ใช่เดาจากรูปทรงของตัวเลขที่วิ่งออกมา
พอดีผมรับงานต่อจากระบบเก่าที่ผู้ติดตั้งเดิมหายไปแล้ว สิ่งที่ได้มาคือสายคู่หนึ่งจากตู้คอนโทรล กับคำบอกเล่าว่า "มันเป็น Modbus นะ" จบแค่นั้น ไม่มีเอกสาร ไม่มี register map ไม่มีแม้แต่ยี่ห้อ ตัวจอเดิมที่เคยโชว์ค่าก็ตายไปพร้อมกับบริการคลาวด์ที่ปิดตัว งานคือทำให้ค่าพวกนั้นกลับมาอ่านได้อีกครั้ง บทความนี้ผมเล่าขั้นที่อยู่ก่อนการถอด register map คือขั้นที่ยังไม่รู้ด้วยซ้ำว่ากำลังอ่านอะไรอยู่ ซึ่งเป็นขั้นที่ผมพลาดเองแล้วตีความผิดอยู่เป็นเดือน
ยี่ห้อ รุ่น สถานที่ และเลขวง IP ในบทความนี้ผมเปลี่ยนเป็นค่าสมมุติหมดแล้วนะครับ ส่วนวิธีไล่กับตัวเลขสถิติคือของจริง
กฎข้อแรกก่อนจะแตะอะไร: อ่านอย่างเดียวเท่านั้น
ระบบที่ผมไปต่อคือระบบที่ กำลังเดินอยู่จริง มีของอยู่ในนั้น มีมอเตอร์ที่ทำงานตามคำสั่งของคอนโทรลเลอร์ ไม่ใช่ชุดทดลองบนโต๊ะ สิ่งแรกที่ผมทำก่อนเขียนสคริปต์บรรทัดแรกคือตั้งกติกาว่าทุกเครื่องมือที่ผมเขียนจะใช้ได้แค่สองฟังก์ชัน
FC01 (อ่าน coil) และ FC03 (อ่าน holding register) เท่านั้น ห้ามใช้ FC05 FC06 FC10 ซึ่งเป็นคำสั่งเขียน แม้เอกสารของอุปกรณ์จะบอกว่าเขียนค่าตั้งต้นหรือสั่งบังคับทำงานได้ก็ตาม เพราะการเขียนค่าผิดหนึ่งครั้งบนระบบที่มีของจริงอยู่ข้างใน แปลว่าของเสียหายทั้งล็อต หรือคอมเพรสเซอร์พัง ซึ่งแพงกว่าค่าจ้างทั้งงานหลายเท่า เครื่องมือที่ผมเขียนทุกตัวมีแบนเนอร์ READ-ONLY ขึ้นตอนสตาร์ท และผม grep หา func=5, func=6, func=16 ในโค้ดก่อนรันทุกครั้ง
มีอีกเรื่องที่ต้องระวังคู่กัน คือ log ของเราต้องเขียนให้ชัดว่าค่าไหน "อ่านมา" ครั้งหนึ่งผมโชว์ค่าตั้งต้นของคอนโทรลเลอร์ใน log เป็น set 100.0C เจ้าของระบบเห็นแล้วตกใจนึกว่าสคริปต์ผมไปตั้งค่าให้ ทั้งที่มันคือค่าที่อ่านมาจาก register เฉยๆ หลังจากนั้นผมเปลี่ยนเป็น set(อ่านมา): 100.0C ทุกจุด เรื่องเล็กแต่ช่วยไม่ให้คนตกใจฟรี
กวาดหาว่าอุปกรณ์คุยที่ความเร็วไหน
ก่อนเริ่มกวาด เช็กก่อนว่าฝั่งระบบปฏิบัติการเห็นพอร์ตจริงและไม่มีใครแย่งใช้อยู่ ผมเคยนั่งไล่อยู่ครึ่งชั่วโมงเพราะมีสคริปต์เก่าของผมเองค้างอยู่อีกหน้าต่าง แย่งพอร์ตกันจนอ่านไม่ขึ้น
ls -l /dev/serial/by-id/ # ดูว่าตัวแปลง USB-RS485 เสียบอยู่จริงไหม
sudo fuser -v /dev/ttyUSB0 # มีโปรเซสไหนถือพอร์ตนี้อยู่หรือเปล่า
stty -F /dev/ttyUSB0 -a | head -3 # ค่าปัจจุบันของพอร์ต
sudo usermod -aG dialout "$USER" # จะได้ไม่ต้อง sudo ทุกครั้ง (ต้อง logout/login)
Modbus RTU ไม่มีกลไกค้นหาอุปกรณ์ในตัว เราต้องเดาพารามิเตอร์แล้วลองยิงเอง ตัวแปรที่ต้องกวาดมีสี่ชั้นคูณกัน
คูณกันแล้วเป็นหมื่นครั้ง ถ้ายิงทุกช่องรอ timeout เต็มทุกครั้งคือรอข้ามคืน ผมเลยทำสองชั้น ชั้นแรกกวาดหยาบด้วย address ตัวอย่างไม่กี่ตัวเพื่อหาชุดพารามิเตอร์ที่มีคนตอบ ชั้นที่สองค่อยกวาด address ครบ 1 ถึง 247 เฉพาะบนชุดพารามิเตอร์ที่เจอแล้ว
#!/usr/bin/env python3
"""bus_sweep.py - READ-ONLY: กวาดหาอุปกรณ์บนบัส RS485
ใช้ FC03 อย่างเดียว ไม่มีคำสั่งเขียนในไฟล์นี้"""
import itertools, time, serial
BAUDS = [1200, 2400, 4800, 9600, 19200, 38400, 115200]
PARITY = ["N", "E", "O"]
BYTES = [8, 7]
PROBE = [1, 2, 10, 20, 32, 247] # ชั้นแรกลองแค่ไม่กี่ที่อยู่
print(">>> READ-ONLY MODE - FC03 only <<<")
def crc16(frame: bytes) -> bytes:
crc = 0xFFFF
for b in frame:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return bytes([crc & 0xFF, crc >> 8])
def probe(ser, slave, addr=0, count=1):
req = bytes([slave, 0x03, addr >> 8, addr & 0xFF, 0x00, count])
ser.reset_input_buffer()
ser.write(req + crc16(req))
return ser.read(256)
for baud, par, bits in itertools.product(BAUDS, PARITY, BYTES):
try:
ser = serial.Serial("/dev/ttyUSB0", baud, bytesize=bits,
parity=par, stopbits=1, timeout=0.35)
except serial.SerialException as e:
print(f" เปิดพอร์ตไม่ได้ {baud}/{bits}{par}1: {e}")
continue
for sl in PROBE:
resp = probe(ser, sl)
if valid(resp, sl): # ตัวตรวจอยู่หัวข้อถัดไป
print(f"[พบ] {baud}/{bits}{par}1 slave={sl} "
f"raw={resp.hex(' ')}")
time.sleep(0.05)
ser.close()
ผลของผมคือเจอชุดเดียวที่มีคนตอบ คือ 19200 8N1 ส่วน baud อื่นเงียบสนิททุกที่อยู่ ตรงนี้ได้ข้อมูลชิ้นแรกที่เชื่อได้แล้ว หน้าตาผลที่รันออกมาประมาณนี้
$ python3 bus_sweep.py
>>> READ-ONLY MODE - FC03 only <<<
... 1200/8N1 เงียบทุกที่อยู่
... 9600/8N1 เงียบทุกที่อยู่
[พบ] 19200/8N1 slave=20 raw=14 03 02 00 00 c4 2b
... 38400/8N1 เงียบทุกที่อยู่
... 115200/8N1 เงียบทุกที่อยู่
สรุป: ชุดที่ใช้ได้ = 19200 8N1 · มีอุปกรณ์ตอบ 1 ที่อยู่
ไบต์แรก 14 คือเลขฐานสิบหกของ 20 ตรงกับที่อยู่ที่เราถาม ไบต์ที่สอง 03 คือฟังก์ชันที่เราส่งไป และสองไบต์ท้ายคือ CRC ที่คำนวณตรง ครบทั้งสามด่านคือของจริง
ตัวกวาดที่ไม่ตรวจ CRC จะให้ที่อยู่ปลอมมาเป็นพวง
รอบแรกที่ผมกวาดครบ 1 ถึง 247 ผลออกมาว่าเจออุปกรณ์ที่ address 13, 15, 17, 20, 22 ไปจนถึง 37 เป็นสิบตัว ผมดีใจอยู่พักหนึ่งว่าเจอเยอะ ก่อนจะสังเกตว่ามันเรียงห่างเท่าๆ กันแบบผิดธรรมชาติ ความจริงคือ มีอุปกรณ์จริงตัวเดียว ที่เหลือคือเศษของเฟรมเดิมที่ค้างอยู่ในบัฟเฟอร์กับเสียงสะท้อนบนสายครึ่งดูเพล็กซ์ ที่บังเอิญมีไบต์แรกตรงกับที่อยู่ที่เรากำลังถาม
ตัวกวาดที่เชื่อได้ต้องตรวจสามชั้น ไม่ใช่แค่ดูว่ามีไบต์ตอบกลับมาไหม
def valid(resp: bytes, slave: int) -> bool:
if len(resp) < 5:
return False
if resp[0] != slave: # กันเฟรมของตัวอื่นที่ค้างในบัฟเฟอร์
return False
if resp[1] not in (0x03, 0x83): # 0x83 = ตอบว่า error ก็ยังนับว่ามีตัวตน
return False
body, got = resp[:-2], resp[-2:]
return crc16(body) == got # ด่านสุดท้ายที่กันของหลอนได้จริง
อุปกรณ์บางตัวตอบช้ามาก โดยเฉพาะคอนโทรลเลอร์ที่มีงานอื่นทำอยู่ ตอนกวาดหยาบผมตั้ง timeout ไว้ 0.35 วินาทีเพื่อความเร็ว แต่ตอนกวาดละเอียดผมขยายเป็น 5 วินาที แล้วเจออุปกรณ์เพิ่มที่รอบแรกไม่เจอ ถ้ากวาดรอบแรกแล้วเงียบ อย่าเพิ่งสรุปว่าไม่มีของ ให้ลองขยาย timeout อีกรอบก่อน
เจอแล้วหนึ่งตัว มี 74 register แต่ยังไม่รู้ว่ามันคืออะไร
พอกวาดสะอาดแล้วผลจริงคือบนบัสมีอุปกรณ์ตอบอยู่ตัวเดียว ที่ address 20 อ่านได้ประมาณ 74 register ค่าบางตัวขยับตลอดเวลา บางตัวนิ่งสนิท ถึงตรงนี้หลายคน (รวมผม) จะเริ่มเดาจากรูปแบบของตัวเลขทันที ซึ่งเป็นจุดที่ผมพลาด
ผมเห็นบล็อกหนึ่งที่มี 16 ค่าเรียงติดกัน หกถึงแปดตัวในนั้นนิ่งเป็นค่าเดียวตลอด ที่เหลือขยับ ผมสรุปเองเลยว่านี่คือช่องวัดอุณหภูมิ 16 ช่อง ที่นิ่งคือช่องที่ค่าค้างอยู่ในแคชของตัวคอนโทรลเลอร์ แล้วสร้างสมมุติฐานต่อยอดอีกสามรอบว่าจะปลุกค่าที่ค้างยังไง ลองรีสตาร์ทอุปกรณ์ ลองอ่านด้วยจังหวะอื่น ลองอ่านเป็นบล็อกใหญ่บ้างเล็กบ้าง เสียเวลาไปหลายสัปดาห์
เป็นคอนโทรลเลอร์รวมศูนย์
16 ค่าเรียงกัน = ช่องอุณหภูมิ 16 ช่อง ที่นิ่งคือแคชค้าง ต้องหาวิธีปลุก สมมุติฐานทุกอันที่ต่อยอดจากตรงนี้ตั้งอยู่บนการเดาชนิดอุปกรณ์ล้วนๆ
เป็นโมดูลวัดกระแส 16 ช่อง
16 ค่านั้นคือช่องวัดกระแสไฟ 16 ช่อง ที่นิ่งคือช่องที่ไม่ได้ต่อหัววัดเข้าไป มันอ่านได้ศูนย์ถูกต้องอยู่แล้ว ไม่มีอะไรเสีย ไม่มีอะไรต้องปลุก และอุปกรณ์ตัวนี้ไม่เคยมีค่าอุณหภูมิอยู่ในตัวเลย
พิสูจน์ว่า register ตัวไหนตายจริง ด้วยข้อมูลไม่ใช่ความรู้สึก
ก่อนจะไปถึงข้อสรุปข้างบน ผมเปลี่ยนวิธีจากการเดาเป็นการเก็บสถิติ ตัวอ่านของผมเก็บค่าดิบทุก register ลงฐานข้อมูลอยู่แล้วทุกวินาที พอสะสมได้หลายหมื่นแถวก็เอามาถามคำถามง่ายๆ ว่า "ตลอดช่วงเวลาที่ผ่านมา register ตัวนี้เคยมีค่ากี่ค่าที่ต่างกัน"
-- ตัวไหน distinct = 1 คือไม่เคยเปลี่ยนเลยตลอด 57,000 ตัวอย่าง
SELECT reg_index,
COUNT(*) AS samples,
COUNT(DISTINCT reg_value) AS distinct_values,
MIN(reg_value) AS min_v,
MAX(reg_value) AS max_v
FROM raw_register_samples
WHERE slave = 20
AND ts_unix >= UNIX_TIMESTAMP() - 86400 * 7
GROUP BY reg_index
ORDER BY distinct_values ASC, reg_index;
ผลที่ได้ตัดข้อถกเถียงจบเลย register หกตัวในบล็อกนั้นมี distinct_values = 1 จากตัวอย่าง 57,000 ครั้ง ตลอดเจ็ดวัน คือตลอดทั้งสัปดาห์ ผ่านทั้งช่วงเครื่องเดิน เครื่องหยุด ทั้งกลางวันกลางคืน มันไม่เคยขยับแม้แต่หน่วยเดียว ค่าที่นิ่งขนาดนั้นไม่ใช่ "แคชค้าง" เพราะแคชค้างจะค้างที่ค่าสุดท้ายที่เคยมีค่า ซึ่งควรเป็นตัวเลขมั่วๆ สักค่า แต่นี่มันนิ่งที่ศูนย์เป๊ะ แปลว่าช่องนั้นไม่มีอะไรต่ออยู่ต่างหาก
ตัวช่วยที่ผมเขียนไว้ใช้ซ้ำคือสรุปโปรไฟล์ของ register ทั้งชุดออกมาเป็นตารางเดียว ดูรอบเดียวรู้เลยว่าตัวไหนน่าสนใจ ตัวไหนตัดทิ้งได้
def profile(rows_by_reg: dict[int, list[int]]):
"""rows_by_reg = {หมายเลข register: [ค่าที่อ่านได้ตามเวลา]}"""
print(f"{'reg':>4} {'n':>7} {'distinct':>9} {'min':>8} {'max':>8} หมายเหตุ")
for reg, vals in sorted(rows_by_reg.items()):
d = len(set(vals))
note = ("ไม่เคยเปลี่ยน - ตายหรือไม่ได้ต่อ" if d == 1 else
"สองสถานะ - น่าจะเป็นสถานะเปิดปิด" if d == 2 else
"ค่าต่อเนื่อง - น่าจะเป็นค่าวัด")
print(f"{reg:>4} {len(vals):>7} {d:>9} {min(vals):>8} {max(vals):>8} {note}")
เก็บค่าดิบสักสองสามวันแล้วนับสามตัวเลขต่อ register คือ จำนวนค่าที่ต่างกัน (distinct=1 คือตายหรือไม่ได้ต่อ), ช่วงต่ำสุดถึงสูงสุด (บอกสเกลว่าน่าจะหารสิบหรือหารร้อย) และ ความถี่ในการเปลี่ยน (ค่าที่ขยับทุกวินาทีกับค่าที่ขยับวันละครั้งคนละประเภทกัน) สามตัวนี้ตัดสมมุติฐานผิดๆ ออกได้เกินครึ่งโดยไม่ต้องเดาเลย
สุดท้ายคำตอบอยู่ที่ป้ายบนตัวเครื่อง
สิ่งที่ปิดเคสนี้ไม่ใช่สคริปต์ที่ผมเขียน แต่เป็นการเดินไปเปิดตู้แล้วถ่ายรูปป้ายบนตัวโมดูลมาดู ตรงนั้นเขียนสเปกไว้ครบว่ามันคือโมดูลเก็บค่ากระแสไฟ 16 ช่อง กินไฟ DC ช่วงหนึ่ง สื่อสารด้วย RS485 พอได้ชื่อรุ่นแล้วไปค้นต่อ เจอโครงการโอเพนซอร์สที่คนอื่นเขียนตัวอ่านของตระกูลนี้ไว้แล้ว มี register map ให้เทียบ ทุกอย่างลงล็อกภายในชั่วโมงเดียว
ผลพวงของการรู้ตัวจริงคือสมมุติฐานเก่าล้มทั้งชุด ค่าอุณหภูมิที่ผมตามหาอยู่ ไม่เคยอยู่บนอุปกรณ์ตัวนี้ มันมาจากคอนโทรลเลอร์คนละตัวที่อยู่บนบัสเดียวกัน แต่สาย A/B ของมันหลุดออกไปตั้งแต่ก่อนผมมารับงาน พอต่อกลับเข้าไปแล้วกวาดใหม่ที่ 19200 ก็เจออุปกรณ์เพิ่มอีกสองตัวที่ address 1 กับ 2 ทันที แต่ละตัวมีค่าอุณหภูมิสองจุด รวมเป็นสี่จุดพอดีกับที่ระบบเดิมเคยแสดง
# ตัวอ่านหลังรู้ตัวจริงแล้ว: แยกจังหวะการอ่านตามชนิดอุปกรณ์
SLAVES = [
# (ที่อยู่, ช่วง register ที่ต้องอ่าน, อ่านทุกกี่วินาที)
(20, [(0, 56), (109, 18)], 1), # กระแส: ต้องทุก 1 วิ เพราะเอาไปคิดพลังงาน
(1, [(0, 2)], 5), # อุณหภูมิ: ตอบช้าและเปลี่ยนช้า อ่านถี่ไม่มีประโยชน์
(2, [(0, 2)], 5),
]
for cycle in itertools.count(1):
for slave, ranges, every in SLAVES:
if (cycle - 1) % every:
continue # ยังไม่ถึงรอบของตัวนี้
for start, count in ranges:
regs = read_holding(slave, start, count) # FC03 เท่านั้น
store(slave, start, regs)
time.sleep(1)
ตอนเจออุปกรณ์วัดอุณหภูมิสองตัวใหม่ๆ ผมยังไม่รู้ว่าหัววัดแต่ละเส้นไปฝังอยู่จุดไหนในห้อง ผมเลยตั้งชื่อบนหน้าจอเป็นเลขกลางๆ ว่าจุดที่ 1 ถึง 4 พร้อมกำกับที่มาแบบ addr·reg ไว้ให้ไล่ย้อนได้ ไม่ตั้งชื่อว่า "กลางห้อง" หรือ "ที่คอยล์" จนกว่าจะเดินไปจับสายยืนยันจริง เพราะป้ายชื่อที่เดาเอาจะกลายเป็นความจริงในหัวคนอ่านทันทีที่ขึ้นจอ แล้วไม่มีใครกลับมาตรวจอีกเลย
เช็กลิสต์ที่ผมใช้กับอุปกรณ์ไม่รู้จักตัวถัดไป
- ตั้งกติกา read-only ก่อนเขียนโค้ดบรรทัดแรก ใส่แบนเนอร์ในสคริปต์ แล้ว grep หาคำสั่งเขียนก่อนรันทุกครั้ง
- กวาดหยาบหาชุด baud/parity/databits ที่มีคนตอบก่อน แล้วค่อยกวาด address ให้ครบบนชุดนั้น
- ตัวกวาดต้องตรวจไบต์ที่อยู่ ไบต์ฟังก์ชัน และ CRC ครบสามชั้น ไม่งั้นได้ที่อยู่ปลอมมาเป็นพวง
- กวาดรอบสองด้วย timeout ยาวขึ้น เผื่ออุปกรณ์ที่ตอบช้า
- เก็บค่าดิบทุก register ลงฐานข้อมูลสักสองสามวันก่อนตีความอะไรทั้งนั้น
- นับ distinct, ช่วงค่า, และความถี่การเปลี่ยน เพื่อคัดตัวที่ตายออกด้วยข้อมูล
- เดินไปถ่ายรูปป้ายที่ตัวเครื่อง อันนี้ควรทำเป็นข้อแรกด้วยซ้ำถ้าเข้าถึงตู้ได้
- ได้ชื่อรุ่นแล้วค้นหาตัวอ่านโอเพนซอร์สของตระกูลเดียวกัน มักมีคนถอด map ไว้แล้ว
บทเรียนจริงของงานนี้สำหรับผมคือ ข้อมูลที่ได้จากบัสบอกได้แค่ว่า รูปแบบของตัวเลขเป็นยังไง มันไม่มีวันบอกว่าตัวเลขนั้นหมายถึงอะไร การเดาชนิดอุปกรณ์จากรูปแบบตัวเลขเป็นสมมุติฐานที่ฟังดูสมเหตุสมผลมากจนเราลืมว่ามันเป็นแค่สมมุติฐาน แล้วต่อยอดไปอีกสามชั้นโดยไม่กลับมาตรวจฐาน ส่วนตัวผมตอนนี้ถือว่าถ้ายังไม่ได้เห็นป้ายที่ตัวเครื่องหรือเอกสารจากผู้ผลิต ทุกการตีความ register ให้ติดป้ายว่า "ยังไม่ยืนยัน" ไว้ก่อนทั้งหมด ใครเจอเคสที่บัสมีของตอบเยอะผิดปกติ ลองไปเช็กเรื่อง CRC ในตัวกวาดก่อนเลยครับ ผมว่าเจอเหมือนกัน ตรงไหนผมเล่าข้ามหรือมีอุปกรณ์ที่พฤติกรรมไม่เหมือนนี้ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย