Skip to content

esp32 uart: Add rs485_half_duplex mode - #2418

Merged
bettio merged 1 commit into
atomvm:release-0.7from
iobee-io:uart-rs485
Oct 30, 2026
Merged

bettio merged 1 commit into
atomvm:release-0.7from
iobee-io:uart-rs485

Conversation

@gabrielmancini

Copy link
Copy Markdown

{mode, rs485_half_duplex} puts the UART in ESP-IDF's RS485 half-duplex mode, with the rts pin driving the transceiver's DE (and /RE). The UART raises it while a frame goes out, lowers it right after the last stop bit, and drops what the receiver picks up in the meantime.

Doing the same from Erlang with a GPIO is only as precise as the scheduler tick (10 ms at 100 Hz). That is longer than a Modbus RTU slave takes to start answering, so the first bytes of the reply are lost.

The default (uart) is unchanged. The programmer's guide now documents rts and mode, with an example.

Tested on a Dingtian DT-R002 (ESP32-D0WD-V3, onboard RS485 transceiver with DE on GPIO33): Modbus RTU at 9600 8N1 against a PC running OpenModSim, with reads and writes answered and no lost replies. That run used our fork, which carries other patches. This branch builds for esp32 on release-0.7 without new warnings, and passes erlfmt and clang-format the same as the base.

@petermm petermm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AMP code nits, pick and choose:

Review: ESP32 UART RS485 half-duplex mode

Reviewed commit: ede847d, esp32 uart: Add rs485_half_duplex mode, against its first parent.

Scope: all four changed files (43 added lines), surrounding UART initialization/cleanup, Erlang API, platform test conventions, and relevant ESP-IDF contracts. The oracle was consulted explicitly; its findings were cross-checked against local code and an independent authoritative-source inspection.

Verdict

No blocking defect found in native mode selection or the new cleanup path. There is one small validation defect and two documentation issues worth correcting before merge. Configuration/lifecycle regression tests are missing. Wire-level correctness has not been demonstrated by this review.

All patches below are suggestions, not applied changes. Priorities: P2 = ordinary-priority correction; P3 = low-priority correction. Outcomes are source-derived, not hardware reproductions.

Findings

1. P3 — Explicit undefined silently selects ordinary UART

Location: uart_driver.c:440–448.

With UART1 available, uart:open("UART1", [{mode, undefined}]) bypasses the new mode lookup. UNDEFINED_ATOM represents both an absent option and an explicitly supplied value. The advertised option accepts only uart | rs485_half_duplex, but this third value is silently accepted as ordinary UART, potentially masking a configuration error and omitting RS485 direction control.

Use the existing invalid-term sentinel to distinguish absence from supplied values. If accepting undefined is intentional, expand the documented contract instead.

--- a/src/platforms/esp32/components/avm_builtins/uart_driver.c
+++ b/src/platforms/esp32/components/avm_builtins/uart_driver.c
@@ -440,6 +440,6 @@
-    term mode_term = interop_kv_get_value_default(opts, ATOM_STR("\x4", "mode"), UNDEFINED_ATOM, global);
+    term mode_term = interop_kv_get_value_default(opts, ATOM_STR("\x4", "mode"), term_invalid_term(), global);
     int mode = UART_MODE_UART;
-    if (mode_term != UNDEFINED_ATOM) {
+    if (!term_is_invalid_term(mode_term)) {
         mode = interop_atom_term_select_int(mode_table, mode_term, global);
         if (mode < 0) {
             ESP_LOGE(TAG, "invalid mode!");

Other unsupported atoms and non-atom values reach the table default; a separate type-check is not needed.

2. P2 — The new RS485 example can disconnect the default ESP32 console output

Location: programmers-guide.md:1988–1995.

The new example opens UART1 with TX on GPIO1 and RX on GPIO3, the ordinary UART0 console pins on the original ESP32. Selecting UART1 does not isolate its GPIO routing from UART0: uart_set_pin() routes GPIO1's output to UART1 TX, replacing the console output route. Users following this example on a classic ESP32 with the default UART0 console can lose console output.

This finding is conditional on that target and console configuration, not universal across ESP32 boards. Use board-qualified, non-console pins in the example; see the combined documentation patch below.

Evidence: ESP32 default UART pins and GPIO matrix TX routing.

3. P3 — Turnaround wording promises more precise timing than ESP-IDF provides

Locations: uart_driver.c:114–118, programmers-guide.md:1988–1991.

“Lowers it right after the last stop bit” suggests an autonomous, precisely timed hardware transition. ESP-IDF's half-duplex implementation lowers RTS in its TX_DONE interrupt handler, after checking that transmission is idle. It avoids an Erlang-level GPIO toggle, but remains subject to interrupt latency. That distinction matters for the advertised fast Modbus turnaround use case.

No missed reply or excessive latency was measured; this is a documentation accuracy finding, not a demonstrated wire-level failure.

Evidence: v5.2.8 TX_DONE ISR, v5.5.5 TX_DONE ISR, and classic ESP32 RTS polarity.

Small documentation suggestions

Alongside findings 2 and 3, document two existing consequences of the new option:

  • Hardware RTS flow control conflicts with RS485 mode. In this API, leave flow_control as none; hardware enables CTS/RTS and causes mode setup to fail after driver installation.
  • USB-Serial-JTAG parses the option but ignores supported mode values; it cannot drive an RS485 RTS GPIO. Preserve the existing parse-and-ignore convention for UART options rather than inventing a USB rejection policy in this review.
--- a/src/platforms/esp32/components/avm_builtins/uart_driver.c
+++ b/src/platforms/esp32/components/avm_builtins/uart_driver.c
@@ -114,8 +114,5 @@
-// With rs485_half_duplex the UART drives RTS as the transceiver's DE: high
-// while a frame goes out, low again right after its last stop bit, and what
-// the receiver picks up meanwhile is dropped. Toggling a GPIO from Erlang
-// instead is only as precise as the scheduler tick (10 ms at 100 Hz), which
-// is longer than a slave takes to start answering.
+// In RS485 half-duplex mode ESP-IDF drives RTS high while transmitting
+// and low from its TX-complete ISR, and handles echo suppression.
 static const AtomStringIntPair mode_table[] = {
     { ATOM_STR("\x4", "uart"), UART_MODE_UART },
     { ATOM_STR("\x11", "rs485_half_duplex"), UART_MODE_RS485_HALF_DUPLEX },
--- a/doc/src/programmers-guide.md
+++ b/doc/src/programmers-guide.md
@@ -1988,8 +1988,11 @@
-In `rs485_half_duplex` mode the UART raises `rts` while a frame goes out and lowers it right
-after the last stop bit, so it can drive the DE (and /RE) pin of a half-duplex RS485
-transceiver; what the receiver picks up while sending is dropped. For example, for a Modbus RTU
-bus with DE on GPIO 33:
+On hardware UARTs, `rs485_half_duplex` uses ESP-IDF to raise `rts` during transmission
+and lower it from the transmit-complete interrupt handler. Turnaround is subject to
+interrupt latency. ESP-IDF also handles echo suppression.
+Connect an appropriate RTS output GPIO to the transceiver's DE (and /RE), and leave
+`flow_control` as `none` (the default); `hardware` conflicts with this mode.
+For example, on an original ESP32 with GPIOs 26, 27 and 33 available, the following
+configuration avoids the default console pins GPIO1 and GPIO3:
 
 ```erlang
-UART = uart:open("UART1", [{rx, 3}, {tx, 1}, {rts, 33}, {speed, 9600}, {mode, rs485_half_duplex}])
+UART = uart:open("UART1", [{rx, 26}, {tx, 27}, {rts, 33}, {speed, 9600}, {mode, rs485_half_duplex}])
 ```
@@ -2007,6 +2010,7 @@
 Pin and line-coding options (`rx`, `tx`, `speed`, `data_bits`, `stop_bits`,
-`flow_control`, `parity`) are parsed and validated like regular UART options,
+`flow_control`, `parity`, `mode`) are parsed and validated like regular UART options,
 but otherwise ignored: the device's USB pins and framing are fixed in hardware.
+Selecting `rs485_half_duplex` for USB-Serial-JTAG does not control an RTS GPIO.
 The `read/1,2` and `write/2` operations behave the same as for a real UART, so
 the same code path works for both hardware UART and the integrated USB CDC
 interface.

Missing regression tests

The commit adds no tests. The existing host tests/libs/estdlib/test_uart.erl exercises Unix PTYs, not ESP32 UART setup. Add a platform test using the existing start/0/ok convention, explicit CMake packaging, and Unity registration.

The following proposed test-body diff intentionally omits the standard license header, which must be included when implementing it. Reserve UART1 for this test, with no installed UART1 console or other driver. It does not transmit or configure GPIO routes.

--- /dev/null
+++ b/src/platforms/esp32/test/main/test_erl_sources/test_uart_mode.erl
@@ -0,0 +1,47 @@
+-module(test_uart_mode).
+-export([start/0]).
+
+start() ->
+    ok = open_close([]),
+    ok = open_close([{mode, uart}]),
+    lists:foreach(
+        fun(Mode) -> expect_badarg([{mode, Mode}]) end,
+        [undefined, invalid_mode, 0, <<"uart">>, "rs485_half_duplex"]
+    ),
+    %% Fails after installation; the next open checks resource cleanup.
+    ok = expect_badarg([
+        {mode, rs485_half_duplex},
+        {flow_control, hardware}
+    ]),
+    ok = open_close([{mode, rs485_half_duplex}]),
+    ok = open_close([]),
+    ok = open_close([{mode, uart}]),
+    Port = uart:open([
+        {peripheral, <<"UART1">>},
+        {mode, rs485_half_duplex}
+    ]),
+    true = is_port(Port),
+    ok = uart:close(Port),
+    ok.
+
+open_close(Opts) ->
+    Port = uart:open("UART1", Opts),
+    try
+        true = is_port(Port),
+        ok
+    after
+        ok = uart:close(Port)
+    end.
+
+expect_badarg(Opts) ->
+    Result =
+        try uart:open("UART1", Opts)
+        catch
+            error:badarg -> badarg
+        end,
+    case Result of
+        badarg -> ok;
+        Port ->
+            ok = uart:close(Port),
+            error({expected_badarg, Opts})
+    end.

Registration suggestions (place the Unity test outside the existing I2C/simulator conditional):

--- a/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt
+++ b/src/platforms/esp32/test/main/test_erl_sources/CMakeLists.txt
@@ -100,6 +100,7 @@
 compile_erlang(test_esp_timer_get_time)
 compile_erlang(test_file)
 compile_erlang(test_i2c)
+compile_erlang(test_uart_mode)
 compile_erlang(test_wifi_example)
 compile_erlang(test_wifi_managed)
 compile_erlang(test_wifi_teardown)
@@ -129,6 +130,7 @@
     test_esp_timer_get_time.beam
     test_file.beam
     test_i2c.beam
+    test_uart_mode.beam
     test_wifi_example.beam
     test_wifi_managed.beam
     test_wifi_teardown.beam
--- a/src/platforms/esp32/test/main/test_main.c
+++ b/src/platforms/esp32/test/main/test_main.c
@@ -299,6 +299,14 @@
 }
 #endif
 
+#if CONFIG_AVM_ENABLE_UART_PORT_DRIVER && CONFIG_ESP_CONSOLE_UART_NUM != 1
+TEST_CASE("test_uart_mode", "[test_run]")
+{
+    term ret_value = avm_test_case("test_uart_mode.beam");
+    TEST_ASSERT(ret_value == OK_ATOM);
+}
+#endif
+
 // SPI SD CARD, is configured for esp32 simulator in diagram.esp32.json
 #if (!CONFIG_ETH_USE_OPENETH && CONFIG_IDF_TARGET_ESP32)
 

The undefined case detects finding 1. The hardware-flow-control conflict followed by successful reopening checks the newly introduced setup-failure cleanup. Omitted and explicit ordinary mode, half-duplex, invalid values, both open arities, and string/binary peripheral names exercise the public contract. Successful reopening alone does not prove that physical full-duplex behavior was restored.

Remaining hardware checks:

  • With an RS485 transceiver and logic analyzer, verify RTS polarity, complete TX frame, echo suppression, and reception of a prompt reply under representative load.
  • Verify half-duplex → close → ordinary UART with actual simultaneous TX/RX, not only configuration acceptance.
  • On a USB-Serial-JTAG target with a separate console, check omitted/explicit ordinary mode, ignored half-duplex, invalid mode, and close/reopen.

Verified contracts and exclusions

  • Mode selection happens before allocation. uart_set_mode() runs after successful installation, as IDF requires. Its failure branch deletes the driver and frees uart_data before listener/mutex registration; no additional queue deletion is needed.
  • Skipping uart_set_mode(UART_MODE_UART) is not a demonstrated bug. Successful uart_param_config() calls uart_hal_init(), which resets hardware mode to ordinary UART; a fresh installation initializes software mode to ordinary UART. See HAL initialization and driver initialization.
  • IDF rejects RS485 with hardware RTS flow control: v5.2.8 mode checks, v5.5.5 mode checks. This is correct failure behavior, not a missing compatibility implementation.
  • The unchecked uart_param_config() and uart_set_pin() returns at lines 515–516 exist in the parent commit. They can mask invalid configuration/pin routing, but are not introduced defects here.
  • The USB path never calls hardware UART mode APIs. No new USB cleanup defect was identified.

Validation limits

This was a source review, not an execution test. No build, flashing, hardware test, or latency measurement was performed. idf.py was not available on the current PATH and IDF_PATH was unset. Suggested tests are not implemented or run.

Authoritative IDF source was inspected at v5.2.8 and v5.5.5, the endpoints of the checkout's CI version list; intermediate versions and all target-specific UART implementations were not independently checked. Classic ESP32 GPIO routing/polarity claims must not be generalized to every target or board.

@gabrielmancini

Copy link
Copy Markdown
Author

Thanks for the review. Pushed 878e183 with the suggestions:

  • {mode, undefined} is now badarg (term_invalid_term() as the default).
  • Docs: ESP-IDF lowers RTS from the transmit-done interrupt, so the turnaround is subject to its latency; flow_control must stay none; USB-Serial-JTAG parses mode but drives no RTS pin; the example uses GPIO 26/27 instead of the console pins.
  • New test_uart_mode, based on your proposal: it passes in QEMU (esp32, memory-check build) and fails without the undefined fix.

A hardware soak with Modbus RTU on a DT-R002 (ESP32, DE on GPIO 33) is next; I'll report here.

@gabrielmancini

Copy link
Copy Markdown
Author

Hardware soak report, as promised.

Setup: Dingtian DT-R002 (ESP32-D0WD-V3, no PSRAM), our fork's VM, whose UART driver matches this PR except the {mode, undefined} → badarg change of 878e183. UART1 tx 1 / rx 3, {mode, rs485_half_duplex} with RTS (DE) on GPIO 33, 9600 8N1. Modbus RTU master in our application against OpenModSim on a PC through a USB-RS485 adapter: 10 holding registers (fn 3) every 5 s, plus a write load of 5 single-register writes (fn 6), each read back, every 10 minutes for the first 8 h.

Result: 15 h 47 min (2026-10-13 19:48 → 2026-10-14 11:35 UTC), ~11,000 reads and 240 writes:

  • 0 timeouts, 0 CRC or framing errors, 0 dropped or stale replies;
  • 0 restarts, no task watchdog, no panic;
  • heap flat the whole night (72 KB free, largest block 52–64 KB).

All 48 write rounds came back with the written values in 3.2–3.3 s, end to end through MQTT.

@gabrielmancini

Copy link
Copy Markdown
Author

About the two red jobs: neither is in the UART code this PR touches.

  • FreeBSD 14.3 failed prime_smp, an SMP test of the VM itself (FreeBSD 13.5 and 15.0 pass on the same commit).
  • esp32c3 / IDF 5.3.6 failed test_socket, which connects to hosts on the Internet; the same test failed on BSD sockets: connect without blocking the scheduler #2416 (esp32 / IDF 5.2.8) the same day, and the other esp32c3 jobs here pass.

@gabrielmancini
gabrielmancini force-pushed the uart-rs485 branch 2 times, most recently from 2662de1 to bad1c84 Compare October 15, 2026 14:12
@gabrielmancini

Copy link
Copy Markdown
Author

Gentle ping: this one has @petermm's approval, and the review nits and the test_uart_mode test are in. CI is green except for FreeBSD 13.5 failing in "Install deps" (infra, release-0.7 itself fails there too) and the BEAM prime_smp run hitting the known test_accept_nowait flake. Is there anything else you'd like changed before it can go in? Happy to rebase or adjust.

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a minor change, everything else is good.

@gabrielmancini

Copy link
Copy Markdown
Author

Rebased on release-0.7 (the conflict was the CHANGELOG only; the code is the reviewed one). @bettio, your request was addressed on 10-16 and @petermm approved: could you take another look?

@bettio bettio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes look good to me, I just suggest to cleanup git history by squashing commits here as described in GIT_WORKFLOW_AND_HISTORY_STYLE_GUIDE.md.

In this case I suggest to keep just one commit instead of 3.

`{mode, rs485_half_duplex}` puts the UART in ESP-IDF's RS485 half-duplex
mode, with the `rts` pin as the transceiver's DE: the UART raises it
while a frame goes out, lowers it right after the last stop bit and
drops what the receiver picks up meanwhile. Toggling a GPIO from Erlang
instead is only as precise as the scheduler tick (10 ms at 100 Hz),
longer than a Modbus slave takes to start answering.

An explicit `{mode, undefined}` is badarg like any other invalid mode.
The docs say that ESP-IDF lowers RTS from its transmit-done interrupt,
that flow_control must stay none, and that USB-Serial-JTAG ignores the
mode; the RS485 example does not use the ESP32 console pins.

test_uart_mode opens and closes UART1 with each mode, checks the invalid
ones, and that a failed open (hardware flow control) frees the UART.

Signed-off-by: Gabriel Mancini <gabriel.mancini@gmail.com>
@gabrielmancini

Copy link
Copy Markdown
Author

Thanks @bettio! Squashed into one commit and rebased on release-0.7. The tree is the same as the reviewed one.

@bettio

bettio commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

Approved. Do not change this PR: it has been already merged locally.

@bettio
bettio merged commit 2ed91ef into atomvm:release-0.7 Oct 30, 2026
187 of 189 checks passed
@gabrielmancini
gabrielmancini deleted the uart-rs485 branch October 30, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants