อ่านมิเตอร์ 3 เฟส DTSU666 ด้วย Raspberry Pi ผ่าน Modbus RS485
มิเตอร์ดิจิทัลตัวละไม่กี่ร้อยบาทอ่านค่าไฟ 3 เฟสได้ครบทุกตัวเลข แต่กว่าจะได้ค่าที่ถูกต้องออกมาจากขา RS485 สองเส้น มันมีกับดักเรื่อง word order กับ scaling ที่ทำให้ตัวเลขเพี้ยนแบบไม่รู้ตัว
พอดีผมอยากรู้ค่าไฟจริงๆ ของโหลด 3 เฟสในห้องเซิร์ฟเวอร์ ไม่ใช่เดาจากบิลปลายเดือน เลยไปเอา DTSU666 Smart Power Sensor มาต่อ มันเป็นมิเตอร์ DIN-rail ที่มีพอร์ต RS485 พูด Modbus RTU ได้ในตัว ราคาถูกกว่ามิเตอร์แบรนด์ฝรั่งหลายเท่า ตัวอ่านผมเขียนด้วย C++ รันบน Raspberry Pi ตัวเดียวไล่อ่านมิเตอร์ทีละหลายตัวบนสาย RS485 เส้นเดียว แล้วเก็บลงฐานข้อมูลก่อนส่งออก บทความนี้ผมเอาโค้ดจริงกับ register address จริงมาแปะให้เลย เพราะะจุดที่คนพลาดกันเยอะคือตอน decode ค่า ไม่ใช่ตอนต่อสาย
ค่าคอนฟิกอย่าง broker, token, รหัส DB ในบทความเป็นค่าตัวอย่างนะครับ ส่วนโครงสร้าง Modbus กับ register map คือของจริงจากเครื่องผมเอง
ต่อสาย RS485 จากมิเตอร์เข้า Raspberry Pi
DTSU666 ใช้ RS485 half-duplex สองเส้นคือ A กับ B (บางรุ่นเขียน A+/B-) ต่อเข้ากับตัวแปลง USB-to-RS485 หรือจะต่อตรงเข้าขา UART ของ Pi ผ่านชิป MAX485 ก็ได้ ฝั่งผมใช้ขา UART ในตัว Pi เลย มันโผล่มาเป็น /dev/serial0 พารามิเตอร์ที่มิเตอร์ตั้งมาจากโรงงานคือ 9600 baud, ไม่มี parity, 8 data bit, 1 stop bit — จำชุดนี้ไว้ เดี๋ยวมันไปโผล่ในโค้ดตรงที่สร้าง context
สาย RS485 บนบัสเดียวกันต่อพ่วงมิเตอร์ได้หลายตัว ขอแค่ตั้ง slave address ในตัวมิเตอร์ให้ไม่ชนกัน แล้วปิด terminator resistor ให้ถูกหัวท้าย ตรงนี้เป็นเรื่องฮาร์ดแวร์ล้วนๆ ไม่เกี่ยวกับโค้ด แต่ถ้าต่อผิดอ่านไม่ขึ้นเลยตั้งแต่แรก
เปิด UART บน Pi ให้เจอ /dev/serial0
ค่า default ของ Raspberry Pi OS จะเอา UART ไปทำ serial console (ให้ล็อกอินผ่านสายอนุกรม) ต้องปิดตรงนั้นก่อน ไม่งั้นแย่งพอร์ตกัน
- รัน sudo raspi-config เข้า Interface Options -> Serial Port ตอบ login shell = No, hardware serial = Yes
- รีบูตแล้วเช็คว่าอุปกรณ์โผล่จริง
- เพิ่ม user ตัวเองเข้ากลุ่ม dialout จะได้ไม่ต้อง sudo ทุกครั้ง
sudo raspi-config # ปิด serial console, เปิด serial hardware
ls -l /dev/serial0 # ควรเห็น symlink ชี้ไป ttyAMA0 หรือ ttyS0
sudo usermod -aG dialout $USER
register map ตัวจริงของ DTSU666
นี่คือหัวใจของงาน — DTSU666 วางค่าวัดทั้งหมดเป็น input/holding register แบบ 32-bit float แต่ละค่ากินสอง register ติดกัน ผมทำเป็น map ของ address จริงกับ scaling factor ไว้ในโค้ด ค่าพวกแรงดันไฟ (Uab/Ubc/Uca และ Ua/Ub/Uc) เริ่มที่ 8192 ไล่ทีละ +2, กระแส (Ia/Ib/Ic) อยู่ 8204-8208 คูณ 0.01, กำลังไฟฟ้าอยู่ช่วง 8210 ขึ้นไป ส่วนพวกพลังงานสะสม (energy) กระโดดไปอยู่บล็อก 16414 ขึ้นไป
// register จริงของ DTSU666 (address, ชนิด, scaling, หน่วย)
map<string, RegisterConfig> registers = {
{"Line Voltage Uab", {8192, "float", 1.0, "V"}},
{"Line Voltage Ubc", {8194, "float", 1.0, "V"}},
{"Line Voltage Uca", {8196, "float", 1.0, "V"}},
{"Phase Voltage Ua", {8198, "float", 1.0, "V"}},
{"Phase Voltage Ub", {8200, "float", 1.0, "V"}},
{"Phase Voltage Uc", {8202, "float", 1.0, "V"}},
{"Phase Current Ia", {8204, "float", 0.01, "A"}},
{"Phase Current Ib", {8206, "float", 0.01, "A"}},
{"Phase Current Ic", {8208, "float", 0.01, "A"}},
{"Conjunction Active Power (W)", {8210, "float", 1.0, "W"}},
{"A Phase Active Power (Pa)", {8212, "float", 1.0, "W"}},
{"B Phase Active Power (Pb)", {8214, "float", 1.0, "W"}},
{"C Phase Active Power (Pc)", {8216, "float", 1.0, "W"}},
{"Conjunction Reactive Power (Qt)", {8218, "float", 1.0, "VAR"}},
{"Frequency (Hz)", {8260, "float", 1.0, "Hz"}},
{"Total Active Power Demand (W)", {8272, "float", 1.0, "W"}},
{"Positive Active Energy (kWh)", {16414, "float", 1.0, "kWh"}},
{"Negative Active Energy (kWh)", {16424, "float", 1.0, "kWh"}},
{"Quadrant 1 Reactive Total Energy (kVarh)", {16434, "float", 1.0, "kVarh"}},
{"Quadrant 2 Reactive Total Energy (kVarh)", {16444, "float", 1.0, "kVarh"}},
{"Quadrant 3 Reactive Total Energy (kVarh)", {16454, "float", 1.0, "kVarh"}},
{"Quadrant 4 Reactive Total Energy (kVarh)", {16464, "float", 1.0, "kVarh"}}
};
เพราะแต่ละค่าเป็น float 32-bit = 2 register ละ 16-bit ถ้าเผลอไล่ address ทีละ +1 คุณจะไปอ่านครึ่งหลังของค่าก่อนหน้าซ้อนกับครึ่งแรกของค่าถัดไป ได้ตัวเลขมั่วที่ดู "เกือบจะใช่" จนหลงได้ง่ายๆ
หัวใจคือการ decode: 2 register รวมเป็น float ตัวเดียว
libmodbus คืนค่ามาเป็น array ของ uint16 ผมต้องเอาสอง register มาต่อกันเป็น 32-bit ก่อน แล้วค่อยตีความ bit pattern นั้นเป็น IEEE-754 float สังเกตว่า DTSU666 วาง word ตัวสูงไว้ก่อน (big-endian word order) ผมเลยเอา registers[0] เลื่อนซ้าย 16 bit แล้ว OR กับ registers[1]
float readRegister(modbus_t* ctx, int address, const string& type, float scalingFactor) {
uint16_t registers[2];
if (modbus_read_registers(ctx, address, 2, registers) == -1) {
logError("Error reading address " + to_string(address) + ": " + modbus_strerror(errno));
return -1; // อ่านไม่ได้
}
// ต่อ 2 register เป็น 32-bit: word สูงมาก่อน
uint32_t rawValue = (registers[0] << 16) | registers[1];
if (type == "float") {
float* floatValue = reinterpret_cast<float*>(&rawValue);
return *floatValue * scalingFactor;
}
return -1;
}
ถ้า decode แล้วได้แรงดันเป็นเลขมหาศาลหรือค่าติดลบแปลกๆ อย่าเพิ่งโทษมิเตอร์ ให้ลองสลับลำดับเป็น (registers[1] << 16) | registers[0] ก่อน เพราะ vendor แต่ละเจ้าวาง word order ไม่เหมือนกัน (ABCD กับ CDAB) ของ DTSU666 ตัวที่ผมใช้คือ word สูงมาก่อนตามโค้ดข้างบน แต่ถ้าเจอรุ่นที่สลับ byte ในแต่ละ word อีก (BADC) ก็ต้อง swap ระดับ byte เพิ่ม
ใครถนัด Python จะเขียน decode เดียวกันด้วย pymodbus ก็ตรรกะเป๊ะกัน อ่าน 2 register เอามาแพ็คเป็น 4 byte แล้ว unpack เป็น float:
from pymodbus.client import ModbusSerialClient
import struct
client = ModbusSerialClient(port="/dev/serial0", baudrate=9600,
parity="N", bytesize=8, stopbits=1)
def read_float(addr, slave, scale=1.0):
rr = client.read_holding_registers(address=addr, count=2, slave=slave)
hi, lo = rr.registers # word สูงมาก่อน (เหมือนฝั่ง C++)
raw = (hi << 16) | lo
return struct.unpack(">f", raw.to_bytes(4, "big"))[0] * scale
client.connect()
uab = read_float(8192, slave=11) # Line Voltage Uab
ia = read_float(8204, slave=11, scale=0.01) # Phase Current Ia
อ่านทีละมิเตอร์: slave 11-14 map เป็น device 1-4
มิเตอร์ผมมี 4 ตัวบนบัสเดียว ตั้ง slave address เป็น 11, 12, 13, 14 ก่อนอ่านทุกครั้งต้องสั่ง modbus_set_slave ให้ตรงตัวที่จะคุยด้วย แล้วค่อยไล่อ่านทุก register จากนั้นค่อยขยับไปมิเตอร์ตัวถัดไป ผม map slave id เป็น device id ให้อ่านง่ายในฐานข้อมูล (11->1, 12->2, ...)
modbus_t* ctx = modbus_new_rtu("/dev/serial0", 9600, 'N', 8, 1);
if (modbus_connect(ctx) == -1) {
logError("Unable to connect: " + string(modbus_strerror(errno)));
modbus_free(ctx);
return -1;
}
vector<int> slaveIds = {11, 12, 13, 14};
map<int, int> slaveToDeviceId = {{11, 1}, {12, 2}, {13, 3}, {14, 4}};
while (true) {
for (int slaveId : slaveIds) {
modbus_set_slave(ctx, slaveId); // เลือกมิเตอร์ก่อนอ่าน
map<string, float> values;
for (const auto& entry : registers) {
float v = readRegister(ctx, entry.second.address,
entry.second.type, entry.second.scalingFactor);
values[entry.first] = v;
}
int deviceId = slaveToDeviceId[slaveId];
writeBatchToMySQL(conn, deviceId, slaveId, values);
}
this_thread::sleep_for(chrono::seconds(3)); // รอบละ 3 วิ
}
ลองคิดภาระบัสดูครับ 22 register x 2 word ต่อมิเตอร์ x 4 มิเตอร์ = อ่าน Modbus 88 ครั้งต่อรอบ ที่ 9600 baud มันไม่เหลือเวลาให้ยิงถี่กว่านี้มากนัก ผมเลยตั้งรอบไว้ 3 วินาทีสำหรับตัวที่ส่งเข้า MySQL ส่วนตัว reader ที่เก็บลง SQLite ผมอ่านถี่กว่า (500ms) แต่ค่อย flush ลง DB นาทีละครั้ง แล้วแต่ว่าอยากได้ความละเอียดกับภาระบัสแค่ไหน
เก็บลงฐานข้อมูล แล้วส่งออก
ผมทำหลายเวอร์ชันตามงานที่ต้องใช้ ตัว reader เก็บลง SQLite ในเครื่อง (ไฟล์เดียวจบ เหมาะกับ Pi ที่อยู่หน้างาน) อีกตัวเก็บลง MySQL/MariaDB (เวลาต้องรวมศูนย์หลาย Pi) โครงตารางเหมือนกันคือหนึ่งแถวต่อหนึ่งรอบอ่านของหนึ่งมิเตอร์ เก็บทุก register เป็นคอลัมน์
const char* createTableQuery = R"(
CREATE TABLE IF NOT EXISTS measurements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_address INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
line_voltage_uab REAL, line_voltage_ubc REAL, line_voltage_uca REAL,
phase_voltage_ua REAL, phase_voltage_ub REAL, phase_voltage_uc REAL,
phase_current_ia REAL, phase_current_ib REAL, phase_current_ic REAL,
conjunction_active_power REAL,
a_phase_active_power REAL, b_phase_active_power REAL, c_phase_active_power REAL,
conjunction_reactive_power REAL, frequency REAL, total_active_power_demand REAL,
positive_active_energy REAL, negative_active_energy REAL,
quadrant_1_reactive_energy REAL, quadrant_2_reactive_energy REAL,
quadrant_3_reactive_energy REAL, quadrant_4_reactive_energy REAL
);
)";
พอมีข้อมูลในเครื่องแล้วส่งออกได้สองทาง ผมเลือกตามสถานการณ์:
ยิงทุกค่าออก broker ทันที
เหมาะกับ dashboard ที่อยากเห็นค่าขยับสดๆ ต่อ TLS ด้วย cert แล้ว publish เป็น JSON ทีละ register ถ้า broker ล่มก็ push message กลับเข้า queue รอส่งใหม่
กวาดแถวที่ยังไม่ส่งเป็นก้อน
ดึงแถวที่ยัง sent_to_api = 0 ทีละ 50 แถว ยิง POST เข้า Laravel API สำเร็จแล้วค่อย mark ว่าส่งแล้ว เหมาะกับที่เน็ตหน้างานไม่นิ่ง เก็บสะสมไว้ในเครื่องแล้วส่งเป็นรอบ
ฝั่ง MQTT ผม publish เป็น JSON ต่อ TLS ตัวคอนฟิกจริงหน้าตาแบบนี้ (ค่า host/user/pass เป็นตัวอย่าง):
const string MQTT_HOST = "mqtt.example.com";
const int MQTT_PORT = 8883;
const string MQTT_TOPIC = "smartmeter/dtsu666";
const string MQTT_USERNAME = "mqtt_user";
const string MQTT_PASSWORD = "REPLACE_ME";
const string MQTT_CERT_PATH = "/opt/mosquitto/mosquitto.crt";
struct mosquitto* mosq = mosquitto_new(nullptr, true, nullptr);
mosquitto_tls_set(mosq, MQTT_CERT_PATH.c_str(), nullptr, nullptr, nullptr, nullptr);
mosquitto_username_pw_set(mosq, MQTT_USERNAME.c_str(), MQTT_PASSWORD.c_str());
mosquitto_connect(mosq, MQTT_HOST.c_str(), MQTT_PORT, 60);
ส่วนตัวส่ง API ผมทำ flag sent_to_api ในตารางกันส่งซ้ำ ดึงมาเป็น batch แล้วค่อยยิง แนวคิดคือถ้าเน็ตหลุด ข้อมูลไม่หาย เพราะยังไม่ได้ mark ว่าส่งแล้ว รอบหน้ามันจะกวาดมาส่งเอง
const char *query = R"(
SELECT measurement_id, JSON_OBJECT(...) AS json_data
FROM measurements
WHERE sent_to_api = 0
ORDER BY measurement_id ASC
LIMIT 50
)";
// ยิงสำเร็จ (HTTP 201/204) แล้วค่อย:
// UPDATE measurements SET sent_to_api = 1 WHERE measurement_id = ?
บั๊กที่ผมเจอเอง: ค่าติดลบโดนทิ้งกลายเป็น 0
อันนี้เป็นจุดที่ผมอยากเตือนที่สุด เพราะะมันเนียนมาก โค้ดผมออกแบบให้ readRegister คืน -1 เวลาอ่านไม่สำเร็จ แล้วฝั่งที่เรียกก็เช็คว่า "ถ้า value >= 0 ค่อยเก็บ ไม่งั้นถือว่าอ่านพลาด set เป็น 0"
float value = readRegister(ctx, config.address, config.type, config.scalingFactor);
if (value >= 0) {
values[name] = value;
} else {
values[name] = 0; // <- ตรงนี้แหละปัญหา
}
ปัญหาคือค่าจริงของมิเตอร์ ติดลบได้โดยชอบธรรม — ตอนโหลดจ่ายไฟย้อนเข้ากริด (เช่นมีโซลาร์เซลล์) active power จะเป็นลบ, reactive power ก็ติดลบได้ตามทิศ พอผมเอา -1 มาใช้เป็นทั้ง "รหัส error" และ "ค่าที่น้อยกว่าศูนย์" มันเลยแยกไม่ออก ค่าลบจริงๆ โดนโยนทิ้งเป็น 0 หมด กราฟการจ่ายไฟย้อนเลยแบนราบทั้งที่ของจริงมันมี
ทางแก้คือแยก "สถานะอ่านสำเร็จ/ไม่สำเร็จ" ออกจาก "ค่าที่อ่านได้" ให้เด็ดขาด — คืน bool ว่าอ่านผ่านไหมผ่าน out-parameter แยกต่างหาก แล้วปล่อยให้ตัวค่าเป็นลบได้อิสระ อย่าเอา -1 มาแทนทั้งสองความหมาย มิเตอร์ที่มีการจ่ายไฟย้อน (net metering) เจอบั๊กนี้แน่นอน
รันเป็น service ด้วย systemd
หน้างานจริงต้องให้มันสตาร์ทเองตอนบูตแล้วมีคนคอยรีสตาร์ทให้เวลาแครช ผมห่อ binary เป็น systemd service ปล่อยให้ systemd จัดการ lifecycle ก่อนอื่น build ให้ได้ตัวรันก่อน — ลิงก์ libmodbus กับ sqlite3 (ถ้าใช้ MySQL/MQTT ก็เพิ่ม -lmysqlclient / -lmosquitto -lpthread)
sudo apt install libmodbus-dev libsqlite3-dev
g++ dtsu666_reader.cpp -o dtsu666 -lmodbus -lsqlite3
# /etc/systemd/system/dtsu666.service
[Unit]
Description=DTSU666 Modbus reader
After=network.target
[Service]
Type=simple
ExecStart=/home/pi/dtsu666
WorkingDirectory=/home/pi
Restart=always
RestartSec=5
User=pi
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now dtsu666.service
sudo systemctl status dtsu666.service
journalctl -u dtsu666 -f # ดู log สดๆ
งาน serial แบบนี้เจอ read timeout เป็นเรื่องปกติเวลามีสัญญาณรบกวนบนบัส ให้ systemd ดึงตัวเองกลับมาเองดีกว่านั่งเฝ้า อย่าลืมว่าถ้า mysqld/mariadbd ยังไม่ขึ้น ตัวส่ง DB ต้องรอ ผมเลยใส่ลูปเช็ค pgrep mariadbd ก่อนเริ่มเขียนจริง
ทั้งหมดนี้คือโครงที่ผมใช้อยู่จริง ใครจะเอาไปต่อยอด เปลี่ยน slave id ตามมิเตอร์ตัวเอง ปรับรอบอ่านตามภาระบัส แล้วเลือกปลายทางว่าจะ SQLite, MySQL, MQTT หรือ REST ก็ได้ตามสะดวกครับ จุดที่ต้องระวังจริงๆ มีอยู่สองที่คือ word order ตอน decode กับเรื่องค่าติดลบที่เล่าไป ที่เหลือเป็นงานประกอบ ตรงไหนผมอธิบายตกหล่นหรือมีรุ่นมิเตอร์ที่ register ไม่ตรงกับนี้ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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