Reading a DTSU666 3-Phase Meter with Raspberry Pi over Modbus RS485
A digital meter costing a few hundred baht can read every 3-phase electrical measurement, but getting correct values out of those two RS485 wires involves word order and scaling traps that can quietly distort the numbers
I wanted to know the actual electrical measurements for the 3-phase loads in my server room instead of guessing from the bill at the end of the month, so I got a DTSU666 Smart Power Sensor and connected it. It is a DIN-rail meter with an RS485 port and built-in Modbus RTU support, costing a fraction of what Western-brand meters do. I wrote the reader in C++, running on a single Raspberry Pi that polls multiple meters one at a time on the same RS485 bus, then stores the readings in a database before exporting them. In this article, I have included the actual code and register addresses because decoding the values is where people often go wrong, rather than wiring.
Configuration values such as the broker, token, and DB password in this article are examples. The Modbus structure and register map are the actual ones from my own setup.
Wiring RS485 from the meter to the Raspberry Pi
The DTSU666 uses two-wire half-duplex RS485: A and B (some models label them A+/B-). Connect them to a USB-to-RS485 adapter, or connect to the Pi's UART pins through a MAX485 chip. I use the Pi's built-in UART, which appears as /dev/serial0. The meter's factory settings are 9600 baud, no parity, 8 data bits, and 1 stop bit — remember these settings; they appear later in the code that creates the context.
Multiple meters can be daisy-chained on the same RS485 bus, as long as each meter has a unique slave address and the termination resistors are correctly fitted at both ends. This is entirely a hardware matter, unrelated to the code, but incorrect wiring will prevent any readings from the start.
Enabling the Pi's UART so /dev/serial0 appears
By default, Raspberry Pi OS uses the UART as a serial console (for logging in over a serial connection). Disable that first, or the two will compete for the port.
- Run sudo raspi-config, go to Interface Options -> Serial Port, and answer login shell = No, hardware serial = Yes
- Reboot and check that the device actually appears
- Add your user to the dialout group so you do not need sudo every time
sudo raspi-config # ปิด serial console, เปิด serial hardware
ls -l /dev/serial0 # ควรเห็น symlink ชี้ไป ttyAMA0 หรือ ttyS0
sudo usermod -aG dialout $USER
The actual DTSU666 register map
This is the core of the job — the DTSU666 stores all measurements as 32-bit floats in input/holding registers, with each value occupying two consecutive registers. In the code, I created a map of the actual addresses and their scaling factors. Voltage values (Uab/Ubc/Uca and Ua/Ub/Uc) start at 8192 and advance by +2, current values (Ia/Ib/Ic) are at 8204-8208 with a multiplier of 0.01, power values start at 8210, and accumulated energy values jump to the block starting at 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"}}
};
Each value is a 32-bit float = 2 registers of 16 bits each. If you accidentally advance the address by +1, you read the second half of the previous value together with the first half of the next one, producing nonsense that looks "almost right" and is easy to trust.
Decoding is the key: combining 2 registers into one float
libmodbus returns an array of uint16 values. I have to combine two registers into 32 bits first, then interpret that bit pattern as an IEEE-754 float. Note that the DTSU666 places the high word first (big-endian word order), so I shift registers[0] left by 16 bits and OR it with 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;
}
If decoding produces an enormous voltage or strange negative values, do not blame the meter yet. Try reversing the order to (registers[1] << 16) | registers[0] first, because vendors use different word orders (ABCD versus CDAB). The DTSU666 I use puts the high word first, as shown in the code above, but if you encounter a model that also reverses the bytes within each word (BADC), you will need to swap the bytes too.
If you prefer Python, the same decoding logic works exactly the same way with pymodbus: read 2 registers, pack them into 4 bytes, then unpack them as a 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
Reading one meter at a time: mapping slaves 11-14 to devices 1-4
I have 4 meters on the same bus, with slave addresses set to 11, 12, 13, and 14. Before each read, I must call modbus_set_slave to select the meter I want to talk to, then read through all its registers before moving on to the next meter. I map the slave id to a device id to make the database easier to read (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 วิ
}
Consider the bus load: 22 registers x 2 words per meter x 4 meters = 88 Modbus reads per cycle. At 9600 baud, there is not much room to poll more frequently. I therefore set a 3-second interval for the version that writes to MySQL. For the reader that stores data in SQLite, I poll more frequently (500ms) but only flush to the DB once a minute, depending on the desired resolution and bus load.
Storing readings in a database, then exporting them
I built several versions for different jobs. One reader stores data locally in SQLite (a single file, suitable for a Pi deployed on site), while another stores it in MySQL/MariaDB (when I need to centralize data from multiple Pis). The table structure is the same: one row per polling cycle per meter, with every register stored as a column.
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
);
)";
Once the data is stored locally, there are two ways to export it. I choose according to the situation:
Send every value to the broker immediately
Suitable for dashboards that need to show live changes. Connect over TLS with a cert, then publish JSON for each register. If the broker goes down, push the message back into the queue to retry later.
Collect unsent rows in batches
Fetch rows where sent_to_api = 0 in batches of 50, POST them to the Laravel API, and mark them as sent only after success. Suitable for sites with unstable internet: accumulate data locally and send it periodically.
For MQTT, I publish JSON over TLS. The actual configuration looks like this (host/user/pass values are examples):
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);
For the API sender, I added a sent_to_api flag to the table to prevent duplicate sends, fetching rows in batches before sending them. The idea is that if the internet connection drops, the data is not lost because it has not been marked as sent; the next cycle will pick it up and send it automatically.
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 = ?
A bug I ran into: negative values discarded and replaced with 0
This is the issue I most want to warn about because it is so subtle. I designed readRegister to return -1 when a read failed, and the caller checked: "Store the value only if value >= 0; otherwise, treat it as a failed read and set it to 0."
float value = readRegister(ctx, config.address, config.type, config.scalingFactor);
if (value >= 0) {
values[name] = value;
} else {
values[name] = 0; // <- ตรงนี้แหละปัญหา
}
The problem is that actual meter values can legitimately be negative — when a load feeds power back into the grid (for example, with solar panels), active power is negative, and reactive power can also be negative depending on direction. By using -1 as both an "error code" and a "value below zero," I could no longer distinguish between them. All legitimate negative values were discarded and replaced with 0, so the feed-in graph was flat even though power was actually flowing back.
The fix is to separate "read success/failure" completely from "the value read" — return a bool to indicate success and pass the reading through a separate out-parameter, leaving the value free to be negative. Do not use -1 for both meanings. Meters with power flowing back to the grid (net metering) will definitely hit this bug.
Running as a service with systemd
On site, it needs to start automatically at boot and have something restart it after a crash. I wrapped the binary in a systemd service and let systemd manage its lifecycle. First, build the executable — link libmodbus and sqlite3 (if using MySQL/MQTT, also add -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 สดๆ
Read timeouts are normal in serial work like this when there is interference on the bus. Let systemd bring the process back automatically instead of watching it yourself. Remember that if mysqld/mariadbd is not running yet, the DB writer has to wait, so I added a loop checking pgrep mariadbd before it starts writing.
This is the setup I actually use. To build on it, change the slave id to match your meters, adjust the polling interval to suit the bus load, and choose SQLite, MySQL, MQTT, or REST as the destination as needed. The two things that really need care are word order during decoding and the negative-value issue described above; the rest is assembly work. If I have missed anything in the explanation or there are meter models whose registers differ from these, I will come back and update the article later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first