diff --git a/lib/circuits_uart.ex b/lib/circuits_uart.ex index acd2798..a4d64b8 100644 --- a/lib/circuits_uart.ex +++ b/lib/circuits_uart.ex @@ -192,6 +192,10 @@ defmodule Circuits.UART do of the Circuits.UART server pid as returned by `start_link/1`. The default value is `:name`. + A pseudo-terminal, such as the ones `socat` makes, can't do parity or any + other than 8 data bits. Opening one on Linux with other settings fails with + `{:error, :einval}`, so use `parity: :none` and `data_bits: 8` for those. + The following options are supported on Linux only: * `:rs485_enabled` - (`true` or `false`) enable RS485 mode. diff --git a/src/uart_comm_unix.c b/src/uart_comm_unix.c index 18832c1..9bdc1a6 100644 --- a/src/uart_comm_unix.c +++ b/src/uart_comm_unix.c @@ -230,6 +230,35 @@ static int clear_custom_speed(int fd) } +/** + * @brief Check that the port can do the data bits and parity asked for + * + * A pseudo-terminal, such as the ones socat makes, can't do parity or any + * other than 8 data bits: the kernel drops the request. tcsetattr() still + * succeeds the first time, since it changes other settings, and then fails + * with EINVAL each time after, since it changes nothing. Fail them all, so + * that opening a pty that worked once doesn't stop working. + * + * @param fd + * @param config + * @return <0 on error, with errno set to EINVAL + */ +static int uart_check_pty(int fd, const struct uart_config *config) +{ + char name[64]; + + if (ttyname_r(fd, name, sizeof(name)) != 0 || strncmp(name, "/dev/pts/", 9) != 0) + return 0; + + if (config->data_bits != 8 || + (config->parity != UART_PARITY_NONE && config->parity != UART_PARITY_IGNORE)) { + errno = EINVAL; + return -1; + } + + return 0; +} + /** * @brief Configure the speed, data bits, stop bits and parity for the port * @@ -239,6 +268,9 @@ static int clear_custom_speed(int fd) */ static int uart_config_line(int fd, const struct uart_config *config) { + if (uart_check_pty(fd, config) < 0) + return -1; + struct termios options; tcgetattr(fd, &options); diff --git a/test/pty_test.exs b/test/pty_test.exs new file mode 100644 index 0000000..f6fdb8d --- /dev/null +++ b/test/pty_test.exs @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: 2026 Tallak Tveide +# +# SPDX-License-Identifier: Apache-2.0 +# +defmodule PtyTest do + use ExUnit.Case + alias Circuits.UART + + @moduledoc """ + A pseudo-terminal can't do parity or characters of other than 8 bits. These tests + make one with socat, so they are skipped where it isn't installed. + """ + + @moduletag skip: if(System.find_executable("socat") == nil, do: "socat is not installed") + + setup do + socat = System.find_executable("socat") + + port = + Port.open({:spawn_executable, socat}, [ + :binary, + :stderr_to_stdout, + args: ["-d", "-d", "pty,raw,echo=0", "pty,raw,echo=0"] + ]) + + {:os_pid, os_pid} = Port.info(port, :os_pid) + on_exit(fn -> System.cmd("kill", [to_string(os_pid)]) end) + + {:ok, uart} = UART.start_link() + %{pty: pty_name(port, ""), uart: uart} + end + + # socat prints "N PTY is /dev/pts/12" for each end. + defp pty_name(port, output) do + case Regex.run(~r/PTY is (\S+)/, output, capture: :all_but_first) do + [name] -> + name + + nil -> + receive do + {^port, {:data, data}} -> pty_name(port, output <> data) + after + 2000 -> flunk("socat did not open a pty: #{output}") + end + end + end + + test "a pty opens again and again with 8 data bits and no parity", %{pty: pty, uart: uart} do + for _ <- 1..3 do + assert :ok = UART.open(uart, pty, speed: 19200, data_bits: 8, parity: :none) + assert :ok = UART.close(uart) + end + end + + test "a pty refuses parity and 7 data bits every time, the first as well", %{ + pty: pty, + uart: uart + } do + for options <- [[parity: :even], [parity: :odd], [parity: :mark], [data_bits: 7]], + _ <- 1..2 do + assert {:error, :einval} = UART.open(uart, pty, [speed: 19200] ++ options) + end + + assert :ok = UART.open(uart, pty, speed: 19200, parity: :none) + end +end