diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c82fb122c..11503a6d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added support for configuring pins and width for sdmmc on ESP32 - Added support for map comprehensions - Added USB CDC port drivers for ESP32, RP2, and STM32 platforms +- Added a Linux `gpio` driver for the generic_unix port (in `avm_unix`) using sysfs ### Changed - Updated network type db() to dbm() to reflect the actual representation of the type diff --git a/libs/avm_unix/src/CMakeLists.txt b/libs/avm_unix/src/CMakeLists.txt index 64d5b32fe0..3a7f7ace18 100644 --- a/libs/avm_unix/src/CMakeLists.txt +++ b/libs/avm_unix/src/CMakeLists.txt @@ -23,6 +23,7 @@ project(avm_unix) include(BuildErlang) set(ERLANG_MODULES + gpio uart ) diff --git a/libs/avm_unix/src/gpio.erl b/libs/avm_unix/src/gpio.erl new file mode 100644 index 0000000000..a1ccc163db --- /dev/null +++ b/libs/avm_unix/src/gpio.erl @@ -0,0 +1,185 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Paul Guyot +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +% + +%%----------------------------------------------------------------------------- +%% @doc GPIO driver module for generic UNIX (Linux), backed sysfs +%% +%% The sysfs base directory defaults to `/sys/class/gpio' and can be overridden +%% with {@link set_sysfs_base/1} (for testing). +%% @end +%%----------------------------------------------------------------------------- +-module(gpio). + +-export([ + init/1, + deinit/1, + set_pin_mode/2, + digital_write/2, + digital_read/1, + set_sysfs_base/1 +]). + +-type pin() :: non_neg_integer(). +%% A Linux GPIO number. +-type direction() :: input | output | output_od. +%% Pin direction. `output_od' (open drain) is treated as `output' by sysfs. +-type low_level() :: low | 0. +-type high_level() :: high | 1. +-type level() :: low_level() | high_level(). +%% A pin level, as an atom or integer. + +-define(DEFAULT_SYSFS_BASE, "/sys/class/gpio"). + +%%----------------------------------------------------------------------------- +%% @param Pin GPIO number to export +%% @returns `ok' or `{error, Reason}' +%% @doc Export a pin (make it available under the sysfs base). Exporting an +%% already-exported pin is not an error. +%% @end +%%----------------------------------------------------------------------------- +-spec init(Pin :: pin()) -> ok | {error, atom()}. +init(Pin) -> + %% Writing an already-exported pin fails with EBUSY; treat that as success. + case write_file(export_path(), integer_to_list(Pin)) of + {error, ebusy} -> ok; + Other -> Other + end. + +%%----------------------------------------------------------------------------- +%% @param Pin GPIO number to unexport +%% @returns `ok' or `{error, Reason}' +%% @doc Unexport a previously exported pin. +%% @end +%%----------------------------------------------------------------------------- +-spec deinit(Pin :: pin()) -> ok | {error, atom()}. +deinit(Pin) -> + write_file(unexport_path(), integer_to_list(Pin)). + +%%----------------------------------------------------------------------------- +%% @param Pin GPIO number +%% @param Mode `input', `output' or `output_od' +%% @returns `ok' or `{error, Reason}' +%% @doc Set the direction of a pin, exporting it first if necessary. +%% @end +%%----------------------------------------------------------------------------- +-spec set_pin_mode(Pin :: pin(), Mode :: direction()) -> ok | {error, atom()}. +set_pin_mode(Pin, Mode) -> + case init(Pin) of + ok -> + write_file(attr_path(Pin, "direction"), direction_string(Mode)); + {error, _Reason} = Error -> + Error + end. + +%%----------------------------------------------------------------------------- +%% @param Pin GPIO number +%% @param Level `high'/`1' or `low'/`0' +%% @returns `ok' or `{error, Reason}' +%% @doc Set the output level of a pin. +%% @end +%%----------------------------------------------------------------------------- +-spec digital_write(Pin :: pin(), Level :: level()) -> ok | {error, atom()}. +digital_write(Pin, Level) -> + write_file(attr_path(Pin, "value"), level_string(Level)). + +%%----------------------------------------------------------------------------- +%% @param Pin GPIO number +%% @returns `high', `low' or `{error, Reason}' +%% @doc Read the level of a pin. +%% @end +%%----------------------------------------------------------------------------- +-spec digital_read(Pin :: pin()) -> high | low | {error, atom()}. +digital_read(Pin) -> + case read_file(attr_path(Pin, "value")) of + {ok, <<$1, _/binary>>} -> high; + {ok, <<$0, _/binary>>} -> low; + {ok, _Other} -> {error, unexpected_value}; + {error, _Reason} = Error -> Error + end. + +%%----------------------------------------------------------------------------- +%% @param Dir base sysfs directory (default `/sys/class/gpio') +%% @returns `ok' +%% @doc Override the sysfs GPIO base directory (mainly for testing). +%% +%% The override is stored in the calling process' dictionary, so it +%% only affects GPIO calls made from that same process. +%% @end +%%----------------------------------------------------------------------------- +-spec set_sysfs_base(Dir :: string()) -> ok. +set_sysfs_base(Dir) -> + _ = erlang:put({?MODULE, sysfs_base}, Dir), + ok. + +%%----------------------------------------------------------------------------- +%% internal +%%----------------------------------------------------------------------------- +sysfs_base() -> + case erlang:get({?MODULE, sysfs_base}) of + undefined -> ?DEFAULT_SYSFS_BASE; + Dir -> Dir + end. + +export_path() -> + sysfs_base() ++ "/export". + +unexport_path() -> + sysfs_base() ++ "/unexport". + +attr_path(Pin, Attr) -> + sysfs_base() ++ "/gpio" ++ integer_to_list(Pin) ++ "/" ++ Attr. + +direction_string(input) -> "in"; +direction_string(output) -> "out"; +direction_string(output_od) -> "out". + +level_string(high) -> "1"; +level_string(1) -> "1"; +level_string(low) -> "0"; +level_string(0) -> "0". + +write_file(Path, Data) -> + case atomvm:posix_open(Path, [o_wronly]) of + {ok, Fd} -> + Result = + case atomvm:posix_write(Fd, iolist_to_binary(Data)) of + {ok, _Count} -> ok; + {error, _Reason} = Error -> Error + end, + _ = atomvm:posix_close(Fd), + Result; + {error, _Reason} = Error -> + Error + end. + +read_file(Path) -> + case atomvm:posix_open(Path, [o_rdonly]) of + {ok, Fd} -> + Result = + case atomvm:posix_read(Fd, 8) of + {ok, Data} -> {ok, Data}; + eof -> {ok, <<>>}; + {error, _Reason} = Error -> Error + end, + _ = atomvm:posix_close(Fd), + Result; + {error, _Reason} = Error -> + Error + end. diff --git a/tests/libs/estdlib/CMakeLists.txt b/tests/libs/estdlib/CMakeLists.txt index 9e70b42d51..945e96c0c7 100644 --- a/tests/libs/estdlib/CMakeLists.txt +++ b/tests/libs/estdlib/CMakeLists.txt @@ -32,6 +32,7 @@ set(ERLANG_MODULES test_gen_statem test_gen_udp test_gen_tcp + test_gpio test_inet test_io_lib test_json diff --git a/tests/libs/estdlib/test_gpio.erl b/tests/libs/estdlib/test_gpio.erl new file mode 100644 index 0000000000..9007f3a873 --- /dev/null +++ b/tests/libs/estdlib/test_gpio.erl @@ -0,0 +1,91 @@ +% +% This file is part of AtomVM. +% +% Copyright 2026 Paul Guyot +% +% Licensed under the Apache License, Version 2.0 (the "License"); +% you may not use this file except in compliance with the License. +% You may obtain a copy of the License at +% +% http://www.apache.org/licenses/LICENSE-2.0 +% +% Unless required by applicable law or agreed to in writing, software +% distributed under the License is distributed on an "AS IS" BASIS, +% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +% See the License for the specific language governing permissions and +% limitations under the License. +% +% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later + +-module(test_gpio). + +-export([test/0]). + +%% The sysfs GPIO `gpio' module is generic_unix-only +test() -> + case + erlang:system_info(machine) == "ATOM" andalso + atomvm:platform() == generic_unix + of + true -> + ok = test_missing_base_no_crash(), + ok = test_set_pin_mode_error_propagates(), + ok = test_roundtrip(), + ok; + false -> + io:format("test_gpio: not supported on this platform, skipping~n"), + ok + end. + +%% A base that does not exist: every public function must return a clean error +%% tuple, never crash. +test_missing_base_no_crash() -> + ok = gpio:set_sysfs_base("/tmp/atomvm_test_gpio_does_not_exist"), + {error, _} = gpio:init(5), + {error, _} = gpio:digital_read(5), + {error, _} = gpio:digital_write(5, high), + ok. + +%% With a non-existent base, `init/1' fails to write /export; +%% `set_pin_mode/2' must propagate that error rather than crash with a +%% `{badmatch, {error, _}}' on `ok = init(Pin)'. +test_set_pin_mode_error_propagates() -> + ok = gpio:set_sysfs_base("/tmp/atomvm_test_gpio_does_not_exist"), + {error, _} = gpio:set_pin_mode(5, output), + ok. + +%% Happy path against a fabricated sysfs tree. +test_roundtrip() -> + Pin = 17, + Base = "/tmp/atomvm_test_gpio_sysfs", + ok = setup_fake_sysfs(Base, Pin), + ok = gpio:set_sysfs_base(Base), + ok = gpio:set_pin_mode(Pin, output), + ok = gpio:digital_write(Pin, high), + high = gpio:digital_read(Pin), + ok = gpio:digital_write(Pin, low), + low = gpio:digital_read(Pin), + %% integer levels are accepted too + ok = gpio:digital_write(Pin, 1), + high = gpio:digital_read(Pin), + ok = gpio:digital_write(Pin, 0), + low = gpio:digital_read(Pin), + ok. + +%% Build /{export,unexport} and /gpio/{direction,value} as +%% regular files so the driver can open them the way it would open the real +%% sysfs attributes. +setup_fake_sysfs(Base, Pin) -> + _ = atomvm:posix_mkdir(Base, 8#755), + ok = create_empty_file(Base ++ "/export"), + ok = create_empty_file(Base ++ "/unexport"), + PinDir = Base ++ "/gpio" ++ integer_to_list(Pin), + _ = atomvm:posix_mkdir(PinDir, 8#755), + ok = create_empty_file(PinDir ++ "/direction"), + ok = create_empty_file(PinDir ++ "/value"), + ok. + +create_empty_file(Path) -> + {ok, Fd} = atomvm:posix_open(Path, [o_wronly, o_creat, o_trunc], 8#644), + ok = atomvm:posix_close(Fd), + ok. diff --git a/tests/libs/estdlib/tests.erl b/tests/libs/estdlib/tests.erl index 922dbe183a..a8021be903 100644 --- a/tests/libs/estdlib/tests.erl +++ b/tests/libs/estdlib/tests.erl @@ -82,6 +82,7 @@ get_non_networking_tests(_OTPVersion) -> test_filename, test_serial_dist, test_uart, + test_gpio, test_serial_dist_socat ].