An industrial-grade C++ daemon designed for TWS power battery packs. It handles low-level Modbus RTU communication with the hardware and broadcasts battery status to multiple clients via a Unix Domain Socket (/tmp/bms.sock).
- High-Performance Communication: Written in C++ with a non-blocking
pollmechanism, supporting 300ms response timeout control. - High Robustness:
- Self-healing Reconnection: Automatically resets and re-initializes the serial port after 5 consecutive read failures.
- Bus Protection: Enforces a 50ms delay between commands to prevent Modbus bus congestion.
- Data Broadcasting: Uses lightweight Unix Domain Sockets, allowing multiple clients (ROS nodes, Python scripts) to subscribe simultaneously.
- Static Info Extraction: Automatically logs firmware/hardware versions, Health (SOH), and cycle counts upon startup.
- Built-in OTA Support: Integrated firmware over-the-air update protocol and automated update service.
- Professional Packaging: Supports one-click
.debpackage generation, optimized for Armbian and other Linux distros, with cross-compilation support.
src/: Core source code (Daemon logic and protocol implementation).include/: Header files (Common data structurebms_status.h).config/: Default configuration templates.service/: Systemd service definition files.scripts/: OTA update Python scripts.udev/: USB serial device permission and alias rules.
Run on the target device (e.g., OrangePi):
dpkg-buildpackagesudo dpkg -i bms-daemon_1.1.3_arm64.debAfter installation, edit the config file to match your actual serial port (e.g., /dev/ttyUSB1):
sudo nano /etc/default/bms_daemon
BMS_TYPE=SCUD485requiresBAUD_RATE=19200. The 485 spec (sec. 2 Serial Attributes) fixes 19200 baud / 8 data bits / no parity / 1 stop bit, while the shipped default is115200, so the type must be changed together with the baud rate. Note thatSerialPort::open()knows only 9600/19200/38400/57600/115200/230400/460800/921600 and silently falls back to 9600 for any other value — a typo then looks like a dead battery (5 read failures, port reopen) instead of a configuration error.
sudo systemctl restart bms # Restart service
sudo journalctl -u bms -f # View real-time logs485 reads start 10 s after the service launches.
bms.servicerunsExecStartPre=/bin/sleep 10because the pack needs time after power-on before it answers on the bus. Every start pays the same 10 s, includingsystemctl restart bms, and/tmp/bms.sockdoes not exist until the wait is over —is_connected()reportingfalsefor the first ~10–12 s of a boot is expected, not a fault.
bms_daemon can mirror every byte it reads from the BMS onto a second UART. This replaces
robopi-uart-bridge from robopi-addon, which ran as a separate process on the same port: two
readers on one tty steal bytes from each other, so the daemon logged intermittent Modbus read
failures and the downstream port only ever received a partial stream. One reader tapping its own
feed hands the downstream port the complete stream without costing the BMS anything.
Configured in /etc/default/bms_daemon:
| Key | Default | Meaning |
|---|---|---|
FORWARD_ENABLE |
1 |
Master switch |
FORWARD_PORT_A |
/dev/ttyS3 |
Source, read only |
FORWARD_PORT_B |
/dev/ttyS7 |
Destination, written only |
FORWARD_BAUD |
115200 |
Destination line speed |
- When
FORWARD_PORT_Ais the BMS port itself, the daemon taps its own reads; it never opens the port a second time. When it names a different port (BMS on CAN, or on another UART) the daemon opens that port and pumps it on its own thread. Names are resolved by device, so a symlink such as/dev/serial/by-id/...pointing at the BMS port is still recognised as the same port. - Only bytes the daemon actually reads are forwarded: the BMS's replies, not the daemon's own queries. The old bridge never saw those transmissions either.
- The destination is opened lazily and retried, so a missing
/dev/ttyS7never holds up the BMS. Forwarding is a monitor feed and is never allowed to slow the Modbus read loop down; if the destination stops accepting, bytes are dropped and reported in the journal. FORWARD_BAUDis independent ofBAUD_RATE. Both default to 115200, but on a bus running at another rate (SCUD485 mandates 19200) setFORWARD_BAUDto match, otherwise the forwarded stream is garbled while still looking plausible.
To turn it off:
sudo sed -i 's/^FORWARD_ENABLE=.*/FORWARD_ENABLE=0/' /etc/default/bms_daemon
sudo systemctl restart bmsThe SCUD485 client separates "the transport is up" from "the battery is answering".
bms_daemon polls the pack with one 0x61 query per second and publishes either a complete
fresh snapshot or nothing at all: a record on the wire always vouches for every field it
carries (power_on included) and never mixes in values from an older round, so a socket that
stays open while the bus is dead is detectable.
is_connected()— socket open and a complete snapshot received within 3 s. A live socket with a dead bus reportsfalse, and so does a socket that has just reconnected and not yet seen a record. Check this before acting on any numeric getter.is_power_on()—falsewhile the data is stale, never the last known value.
Numeric getters (get_voltage(), get_percentage(), ...) keep returning the last received
snapshot on purpose, so a short bus dropout does not look like a missing battery. TWS and
GF485 clients are unchanged.
- Rename the new firmware to
firmware.binand place it in the/opt/bms/directory. - Execute the update command:
sudo systemctl start bms_ota- Monitor progress via
journalctl -u bms_ota -f. The BMS will reset automatically after the update.