// SPDX-License-Identifier: Apache-2.0 // Copyright 2023 Pionix GmbH and Contributors to EVerest // TODOs: // - sometimes we receive 0 bytes from sofar, find out why // - implement echo removal for chargebyte // - implement GPIO to switch rx/tx #include "tiny_modbus_rtu.hpp" #include "crc16.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace tiny_modbus { std::string FunctionCode_to_string(FunctionCode fc) { switch (fc) { case FunctionCode::READ_COILS: return "READ_COILS"; case FunctionCode::READ_DISCRETE_INPUTS: return "READ_DISCRETE_INPUTS"; case FunctionCode::READ_MULTIPLE_HOLDING_REGISTERS: return "READ_MULTIPLE_HOLDING_REGISTERS"; case FunctionCode::READ_INPUT_REGISTERS: return "READ_INPUT_REGISTERS"; case FunctionCode::WRITE_SINGLE_COIL: return "WRITE_SINGLE_COIL"; case FunctionCode::WRITE_SINGLE_HOLDING_REGISTER: return "WRITE_SINGLE_HOLDING_REGISTER"; case FunctionCode::WRITE_MULTIPLE_COILS: return "WRITE_MULTIPLE_COILS"; case FunctionCode::WRITE_MULTIPLE_HOLDING_REGISTERS: return "WRITE_MULTIPLE_HOLDING_REGISTERS"; default: return "unknown"; } } std::string FunctionCode_to_string_with_hex(FunctionCode fc) { return fmt::format("{}({:#04x})", FunctionCode_to_string(fc), (unsigned int)fc); } std::ostream& operator<<(std::ostream& os, const FunctionCode& fc) { os << FunctionCode_to_string_with_hex(fc); return os; } // This is a replacement for system library tcdrain(). // tcdrain() returns when all bytes are written to the UART, but it actually returns about 10msecs or more after the // last byte has been written. This function tries to return as fast as possible instead. static void fast_tcdrain(int fd) { // in user space, the only way to find out if there are still bits to be shiftet out is to poll line status register // as fast as we can uint32_t lsr; do { ioctl(fd, TIOCSERGETLSR, &lsr); } while (!(lsr & TIOCSER_TEMT)); } static auto check_for_exception(uint8_t received_function_code) { return received_function_code & (1 << 7); } static void clear_exception_bit(uint8_t& received_function_code) { received_function_code &= ~(1 << 7); } static std::string hexdump(const uint8_t* msg, int msg_len) { std::stringstream ss; for (int i = 0; i < msg_len; i++) { ss << "<" << std::nouppercase << std::setfill('0') << std::setw(2) << std::hex << (int)msg[i] << ">"; } return ss.str(); } static void append_checksum(uint8_t* msg, int msg_len) { if (msg_len < 5) return; uint16_t crc_sum = calculate_modbus_crc16(msg, msg_len - 2); memcpy(msg + msg_len - 2, &crc_sum, 2); } static bool validate_checksum(const uint8_t* msg, int msg_len) { if (msg_len < 5) return false; // check crc uint16_t crc_sum = calculate_modbus_crc16(msg, msg_len - 2); uint16_t crc_msg; memcpy(&crc_msg, msg + msg_len - 2, 2); return (crc_msg == crc_sum); } static std::vector decode_reply(const uint8_t* buf, int len, uint8_t expected_device_address, FunctionCode function) { std::vector result; if (len == 0) { throw TimeoutException("Packet receive timeout"); } else if (len < MODBUS_MIN_REPLY_SIZE) { throw ShortPacketException(fmt::format("Packet too small: only {} bytes", len)); } if (expected_device_address != buf[DEVICE_ADDRESS_POS]) { throw AddressMismatchException(fmt::format("Device address mismatch: expected: {} received: {}", expected_device_address, buf[DEVICE_ADDRESS_POS]) + ": " + hexdump(buf, len)); } bool exception = false; uint8_t function_code_recvd = buf[FUNCTION_CODE_POS]; if (check_for_exception(function_code_recvd)) { // highest bit is set for exception reply exception = true; // clear error bit clear_exception_bit(function_code_recvd); } if (function != function_code_recvd) { throw FunctionCodeMismatchException(fmt::format("Function code mismatch: expected: {} received: {}", static_cast>(function), function_code_recvd)); } if (!validate_checksum(buf, len)) { throw ChecksumErrorException("Retrieved Modbus checksum does not match calculated value."); } if (exception) { // handle exception message uint8_t err_code = buf[RES_EXCEPTION_CODE]; switch (err_code) { case 0x01: throw ModbusException("Modbus exception: Illegal function"); break; case 0x02: throw ModbusException("Modbus exception: Illegal data address"); break; case 0x03: throw ModbusException("Modbus exception: Illegal data value"); break; case 0x04: throw ModbusException("Modbus exception: Client device failure"); break; case 0x05: throw ModbusException("Modbus ACK"); break; case 0x06: throw ModbusException("Modbus exception: Client device busy"); break; case 0x07: throw ModbusException("Modbus exception: NACK"); break; case 0x08: throw ModbusException("Modbus exception: Memory parity error"); break; case 0x09: throw ModbusException("Modbus exception: Out of resources"); break; case 0x0A: throw ModbusException("Modbus exception: Gateway path unavailable"); break; case 0x0B: throw ModbusException("Modbus exception: Gateway target device failed to respond"); break; default: throw ModbusException("Modbus exception: Unknown"); } } // For a write reply we always get 4 bytes uint8_t byte_cnt = 4; int start_of_result = RES_TX_START_OF_PAYLOAD; bool even_byte_cnt_expected = false; // Was it a read reply? switch (function) { case FunctionCode::WRITE_SINGLE_COIL: case FunctionCode::WRITE_SINGLE_HOLDING_REGISTER: case FunctionCode::WRITE_MULTIPLE_COILS: case FunctionCode::WRITE_MULTIPLE_HOLDING_REGISTERS: // no - nothing to do break; case FunctionCode::READ_MULTIPLE_HOLDING_REGISTERS: case FunctionCode::READ_INPUT_REGISTERS: // yes - for 16-bit wide registers thus we can assume an even byte count even_byte_cnt_expected = true; [[fallthrough]]; case FunctionCode::READ_COILS: case FunctionCode::READ_DISCRETE_INPUTS: // yes // adapt byte count and starting pos byte_cnt = buf[RES_RX_LEN_POS]; start_of_result = RES_RX_START_OF_PAYLOAD; break; default: throw std::logic_error("Missing implementation for function code " + FunctionCode_to_string_with_hex(function)); } // check if result is completely in received data if (start_of_result + byte_cnt > len) { throw IncompletePacketException("Result data not completely in received message."); } // check even number of bytes if (even_byte_cnt_expected && byte_cnt % 2 == 1) { throw OddByteCountException("For " + FunctionCode_to_string_with_hex(function) + " an even byte count is expected in the response."); } // ready to copy actual result data to output, so pre-allocate enough memory for the output result.reserve((byte_cnt + 1) / 2); for (int i = start_of_result; i < start_of_result + byte_cnt; i += 2) { uint16_t t = 0; const size_t num_bytes_to_copy = (i < len - 1) ? 2 : 1; memcpy(&t, buf + i, num_bytes_to_copy); t = be16toh(t); result.push_back(t); } return result; } TinyModbusRTU::~TinyModbusRTU() { if (fd != -1) close(fd); } bool TinyModbusRTU::open_device(const std::string& device, int _baud, bool _ignore_echo, const Everest::GpioSettings& rxtx_gpio_settings, const Parity parity, bool rtscts, std::chrono::milliseconds _initial_timeout, std::chrono::milliseconds _within_message_timeout) { initial_timeout = _initial_timeout; within_message_timeout = _within_message_timeout; ignore_echo = _ignore_echo; rxtx_gpio.open(rxtx_gpio_settings); rxtx_gpio.set_output(true); fd = open(device.c_str(), O_RDWR | O_NOCTTY | O_SYNC); if (fd < 0) { EVLOG_error << fmt::format("Serial: error {} opening {}: {}\n", errno, device, strerror(errno)); return false; } int baud; switch (_baud) { case 9600: baud = B9600; break; case 19200: baud = B19200; break; case 38400: baud = B38400; break; case 57600: baud = B57600; break; case 115200: baud = B115200; break; case 230400: baud = B230400; break; default: return false; } struct termios tty; if (tcgetattr(fd, &tty) != 0) { printf("Serial: error %d from tcgetattr\n", errno); return false; } cfsetospeed(&tty, baud); cfsetispeed(&tty, baud); tty.c_cflag = (tty.c_cflag & ~CSIZE) | CS8; // 8-bit chars // disable IGNBRK for mismatched speed tests; otherwise receive break // as \000 chars tty.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON | IXOFF | IXANY); tty.c_lflag = 0; // no signaling chars, no echo, // no canonical processing tty.c_oflag = 0; // no remapping, no delays tty.c_cc[VMIN] = 1; // read blocks tty.c_cc[VTIME] = 1; // 0.1 seconds inter character read timeout after first byte was received tty.c_cflag |= (CLOCAL | CREAD); // ignore modem controls, // enable reading if (parity == Parity::ODD) { tty.c_cflag |= (PARENB | PARODD); // odd parity } else if (parity == Parity::EVEN) { // even parity tty.c_cflag &= ~PARODD; tty.c_cflag |= PARENB; } else { tty.c_cflag &= ~(PARENB | PARODD); // shut off parity } tty.c_cflag &= ~CSTOPB; // 1 Stop bit if (rtscts) { tty.c_cflag |= CRTSCTS; } else { tty.c_cflag &= ~CRTSCTS; } if (tcsetattr(fd, TCSANOW, &tty) != 0) { printf("Serial: error %d from tcsetattr\n", errno); return false; } return true; } int TinyModbusRTU::read_reply(uint8_t* rxbuf, int rxbuf_len) { if (fd == -1) { return 0; } // Lambda to convert std::chrono to timeval. auto to_timeval = [](const auto& time) { using namespace std::chrono; struct timeval timeout; auto sec = duration_cast(time); timeout.tv_sec = sec.count(); timeout.tv_usec = duration_cast(time - sec).count(); return timeout; }; auto timeout = to_timeval(initial_timeout); const auto within_message_timeval = to_timeval(within_message_timeout); fd_set set; FD_ZERO(&set); FD_SET(fd, &set); int bytes_read_total = 0; while (true) { int rv = select(fd + 1, &set, NULL, NULL, &timeout); timeout = within_message_timeval; if (rv == -1) { // error in select function call perror("txrx: select:"); break; } else if (rv == 0) { // no more bytes to read within timeout, so transfer is complete break; } else { // received more bytes, add them to buffer // do we have space in the rx buffer left? if (bytes_read_total >= rxbuf_len) { // no buffer space left, but more to read. break; } int bytes_read = read(fd, rxbuf + bytes_read_total, rxbuf_len - bytes_read_total); if (bytes_read > 0) { bytes_read_total += bytes_read; } } } return bytes_read_total; } std::vector TinyModbusRTU::txrx(uint8_t device_address, FunctionCode function, uint16_t first_register_address, uint16_t register_quantity, uint16_t max_packet_size, bool wait_for_reply, std::vector request) { // This only supports chunking of the read-requests. std::vector out; if (max_packet_size < MODBUS_MIN_REPLY_SIZE + 2) { EVLOG_error << fmt::format("Max packet size too small: {}", max_packet_size); return {}; } const uint16_t register_chunk = (max_packet_size - MODBUS_MIN_REPLY_SIZE) / 2; size_t written_elements = 0; while (register_quantity) { const auto current_register_quantity = std::min(register_quantity, register_chunk); std::vector current_request; if (request.size() > written_elements + current_register_quantity) { current_request = std::vector(request.begin() + written_elements, request.begin() + written_elements + current_register_quantity); written_elements += current_register_quantity; } else { current_request = std::vector(request.begin() + written_elements, request.end()); written_elements = request.size(); } const auto res = txrx_impl(device_address, function, first_register_address, current_register_quantity, wait_for_reply, current_request); // We failed to read/write. if (res.empty()) { return res; } out.insert(out.end(), res.begin(), res.end()); first_register_address += current_register_quantity; register_quantity -= current_register_quantity; } return out; } std::vector _make_single_write_request(uint8_t device_address, FunctionCode function, uint16_t register_address, bool wait_for_reply, uint16_t data) { const int req_len = 8; std::vector req(req_len); req[DEVICE_ADDRESS_POS] = device_address; req[FUNCTION_CODE_POS] = static_cast(function); register_address = htobe16(register_address); data = htobe16(data); memcpy(req.data() + REQ_TX_FIRST_REGISTER_ADDR_POS, ®ister_address, 2); memcpy(req.data() + REQ_TX_SINGLE_REG_PAYLOAD_POS, &data, 2); append_checksum(req.data(), req_len); return req; } std::vector _make_generic_request(uint8_t device_address, FunctionCode function, uint16_t first_register_address, uint16_t register_quantity, std::vector request) { // size of request int req_len = (request.size() == 0 ? 0 : 2 * request.size() + 1) + MODBUS_BASE_PAYLOAD_SIZE; std::vector req(req_len); // add header req[DEVICE_ADDRESS_POS] = device_address; req[FUNCTION_CODE_POS] = function; first_register_address = htobe16(first_register_address); register_quantity = htobe16(register_quantity); memcpy(req.data() + REQ_TX_FIRST_REGISTER_ADDR_POS, &first_register_address, 2); memcpy(req.data() + REQ_TX_QUANTITY_POS, ®ister_quantity, 2); if (function == FunctionCode::WRITE_MULTIPLE_HOLDING_REGISTERS) { // write byte count req[REQ_TX_MULTIPLE_REG_BYTE_COUNT_POS] = request.size() * 2; // add request data int i = REQ_TX_MULTIPLE_REG_BYTE_COUNT_POS + 1; for (auto r : request) { r = htobe16(r); memcpy(req.data() + i, &r, 2); i += 2; } } // set checksum in the last 2 bytes append_checksum(req.data(), req_len); return req; } /* This function transmits a modbus request and waits for the reply. Parameter request is optional and is only used for writing multiple registers. */ std::vector TinyModbusRTU::txrx_impl(uint8_t device_address, FunctionCode function, uint16_t first_register_address, uint16_t register_quantity, bool wait_for_reply, std::vector request) { { if (fd == -1) { return {}; } auto req = function == FunctionCode::WRITE_SINGLE_HOLDING_REGISTER or function == FunctionCode::WRITE_SINGLE_COIL ? _make_single_write_request(device_address, function, first_register_address, wait_for_reply, request.at(0)) : _make_generic_request(device_address, function, first_register_address, register_quantity, request); // clear input and output buffer tcflush(fd, TCIOFLUSH); // write to serial port rxtx_gpio.set(false); uint8_t* buffer = req.data(); ssize_t written = 0; while (written < req.size()) { ssize_t c = write(fd, &buffer[written], req.size() - written); if (c == -1) throw std::system_error(errno, std::generic_category(), "Could not send Modbus request"); written += c; } if (rxtx_gpio.is_ready()) { // if we are using GPIO to switch between RX/TX, use the fast version of tcdrain with exact timing fast_tcdrain(fd); } else { // without GPIO switching, use regular tcdrain as not all UART drivers implement the ioctl tcdrain(fd); } rxtx_gpio.set(true); if (ignore_echo) { // read back echo of what we sent and ignore it read_reply(req.data(), req.size()); } } if (wait_for_reply) { // wait for reply uint8_t rxbuf[MODBUS_MAX_REPLY_SIZE]; int bytes_read_total = read_reply(rxbuf, sizeof(rxbuf)); return decode_reply(rxbuf, bytes_read_total, device_address, function); } return std::vector(); } } // namespace tiny_modbus