diff --git a/.github/scripts/start-mosquitto.sh b/.github/scripts/start-mosquitto.sh new file mode 100755 index 0000000..d2841c5 --- /dev/null +++ b/.github/scripts/start-mosquitto.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Davide Bettio + +set -euo pipefail + +WORKDIR="$(pwd)/mosquitto" +mkdir -p "$WORKDIR/config" "$WORKDIR/certs" + +openssl req -x509 -nodes -newkey rsa:2048 -days 1 \ + -keyout "$WORKDIR/certs/server.key" \ + -out "$WORKDIR/certs/server.crt" \ + -subj "/CN=localhost" +chmod 644 "$WORKDIR/certs/server.key" "$WORKDIR/certs/server.crt" + +cat > "$WORKDIR/config/mosquitto.conf" <<'EOF' +persistence false +allow_anonymous true + +listener 1883 +listener 1884 + +listener 8883 +certfile /mosquitto/certs/server.crt +keyfile /mosquitto/certs/server.key +EOF + +docker run -d --name mosquitto \ + -p 1883:1883 -p 1884:1884 -p 8883:8883 \ + -v "$WORKDIR/config:/mosquitto/config" \ + -v "$WORKDIR/certs:/mosquitto/certs" \ + eclipse-mosquitto:2.0 + +for i in $(seq 1 15); do + if nc -z 127.0.0.1 1883 && nc -z 127.0.0.1 8883; then + echo "mosquitto ready on 1883/1884/8883" + exit 0 + fi + sleep 1 +done + +echo "mosquitto did not become ready in 15s; container logs:" >&2 +docker logs mosquitto >&2 || true +exit 1 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..f0277f4 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,97 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Davide Bettio + +name: Test +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-24.04 + strategy: + matrix: + otp: ['28.0'] + rebar3: ['3.25.1'] + defaults: + run: + working-directory: amqtt_client + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + rebar3-version: ${{matrix.rebar3}} + - name: Compile + run: rebar3 compile + - name: Format check + run: rebar3 fmt --check + - name: Test + run: rebar3 eunit + - name: Dialyzer + run: rebar3 dialyzer + + examples: + runs-on: ubuntu-24.04 + strategy: + matrix: + otp: ['28.0'] + rebar3: ['3.25.1'] + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + rebar3-version: ${{matrix.rebar3}} + - name: Start local Mosquitto + run: bash .github/scripts/start-mosquitto.sh + - name: Route test.mosquitto.org to local broker + run: echo "127.0.0.1 test.mosquitto.org" | sudo tee -a /etc/hosts + - name: Compile all examples + run: | + for ex in examples/*/; do + (cd "$ex" && rebar3 compile) + done + - name: Format check all examples + run: | + for ex in examples/*/; do + (cd "$ex" && rebar3 fmt --check) + done + - name: Dialyzer all examples + run: | + for ex in examples/*/; do + (cd "$ex" && rebar3 dialyzer) + done + - name: Run mqtt_publish_example + working-directory: examples/mqtt_publish_example + run: erl -pa _build/default/lib/*/ebin -noshell -eval "case mqtt_publish_example:start() of ok -> halt(0); _ -> halt(1) end." + - name: Run mqtt_tls_publish_example + working-directory: examples/mqtt_tls_publish_example + run: erl -pa _build/default/lib/*/ebin -noshell -eval "case mqtt_tls_publish_example:start() of ok -> halt(0); _ -> halt(1) end." + - name: Run mqtt_subscribe_example + working-directory: examples/mqtt_subscribe_example + run: erl -pa _build/default/lib/*/ebin -noshell -eval "case mqtt_subscribe_example:start() of ok -> halt(0); _ -> halt(1) end." + + e2e-otp: + runs-on: ubuntu-24.04 + strategy: + matrix: + otp: ['28.0'] + rebar3: ['3.25.1'] + steps: + - uses: actions/checkout@v2 + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + rebar3-version: ${{matrix.rebar3}} + - name: Start local Mosquitto + run: bash .github/scripts/start-mosquitto.sh + - name: Build e2e + working-directory: e2e/client_pub_sub_e2e + run: rebar3 compile + - name: Format check e2e + working-directory: e2e/client_pub_sub_e2e + run: rebar3 fmt --check + - name: Run e2e against local broker (TLS) + working-directory: e2e/client_pub_sub_e2e + env: + AMQTT_E2E_USE_TLS: "true" + run: erl -pa _build/default/lib/*/ebin -noshell -eval "case client_pub_sub_e2e:start() of ok -> halt(0); _ -> halt(1) end." diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/README.md b/README.md index e038396..6c38c93 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,165 @@ --> # amqtt + +Erlang MQTT library optimized for [AtomVM](https://atomvm.org/) (an Erlang +VM for embedded devices). Also runs unchanged on stock Erlang/OTP, so the +same code can be developed and tested on a workstation and deployed on +microcontrollers. + +## amqtt_client + +An MQTT **3.1.1** client. Supports plain TCP and TLS, QoS 0 / 1 / 2 for +both publish and subscribe, persistent sessions, retain, last-will, +username/password authentication, and an optional manual-ACK mode for +end-to-end exactly-once delivery at the application boundary. + +Quick reference (including the list of messages the client sends to its +owner process) is in [`amqtt_client/README.md`](amqtt_client/README.md). + +## Adding to your project + +### rebar3 + +```erlang +{deps, [ + {amqtt_client, + {git, "https://github.com/atomvm/amqtt.git", + {branch, "main"}}} +]}. +``` + +### Elixir (`mix.exs`) + +```elixir +defp deps do + [ + {:amqtt_client, + git: "https://github.com/atomvm/amqtt.git", + branch: "main", + sparse: "amqtt_client"} + ] +end +``` + +The library lives under the `amqtt_client/` subdirectory of the repo; +`sparse:` (rebar3 git deps automatically use the matching app name) keeps +mix from building the e2e / examples projects. + +## Quick usage + +### Connect (plain TCP) + +Erlang: + +```erlang +{ok, Client} = amqtt_client:connect(#{ + host => "broker.example.com", + port => 1883, + client_id => <<"my_client">>, + keep_alive_seconds => 60 +}), +receive + {mqtt, Client, connack, #{return_code := 0}} -> ok +end. +``` + +Elixir: + +```elixir +{:ok, client} = :amqtt_client.connect(%{ + host: "broker.example.com", + port: 1883, + client_id: "my_client", + keep_alive_seconds: 60 +}) + +receive do + {:mqtt, ^client, :connack, %{return_code: 0}} -> :ok +end +``` + +### Connect (TLS) + +Erlang: + +```erlang +{ok, Client} = amqtt_client:connect(#{ + host => "broker.example.com", + port => 8883, + transport => ssl, + ssl_opts => [{verify, verify_none}], + client_id => <<"my_client">>, + keep_alive_seconds => 60 +}). +``` + +Elixir: + +```elixir +{:ok, client} = :amqtt_client.connect(%{ + host: "broker.example.com", + port: 8883, + transport: :ssl, + ssl_opts: [{:verify, :verify_none}], + client_id: "my_client", + keep_alive_seconds: 60 +}) +``` + +### Subscribe + +Erlang: + +```erlang +{ok, _GrantedQoSs} = amqtt_client:subscribe(Client, [{<<"sensor/#">>, 1}]), +receive + {mqtt, Client, publish, #{topic := Topic, message := Payload}} -> + io:format("~s: ~p~n", [Topic, Payload]) +end. +``` + +Elixir: + +```elixir +{:ok, _granted_qoss} = :amqtt_client.subscribe(client, [{"sensor/#", 1}]) + +receive do + {:mqtt, ^client, :publish, %{topic: topic, message: payload}} -> + IO.puts("#{topic}: #{inspect(payload)}") +end +``` + +### Publish + +Erlang: + +```erlang +%% QoS 0 -- fire and forget +ok = amqtt_client:publish(Client, <<"sensor/temp">>, <<"22.5">>, 0). + +%% QoS 1 -- blocks until PUBACK +{ok, _PacketId} = amqtt_client:publish(Client, <<"sensor/temp">>, <<"22.5">>, 1). + +%% With retain +{ok, _} = amqtt_client:publish(Client, <<"status">>, <<"online">>, 1, + #{retain => true}). +``` + +Elixir: + +```elixir +# QoS 0 -- fire and forget +:ok = :amqtt_client.publish(client, "sensor/temp", "22.5", 0) + +# QoS 1 -- blocks until PUBACK +{:ok, _packet_id} = :amqtt_client.publish(client, "sensor/temp", "22.5", 1) + +# With retain +{:ok, _} = :amqtt_client.publish(client, "status", "online", 1, %{retain: true}) +``` + +See [`examples/`](examples/) for runnable demos (publish, TLS publish, +wildcard subscribe, QoS 2 with persistent session + manual ACK, JSON +subscribe) and [`e2e/client_pub_sub_e2e/`](e2e/client_pub_sub_e2e/) for an +end-to-end test you can run on AtomVM or stock OTP. diff --git a/amqtt_client/README.md b/amqtt_client/README.md new file mode 100644 index 0000000..f4e7450 --- /dev/null +++ b/amqtt_client/README.md @@ -0,0 +1,240 @@ + + +# amqtt_client + +An MQTT **3.1.1** client. Supports plain TCP and TLS, QoS 0 / 1 / 2 for +both publish and subscribe, persistent sessions, retain, last-will, +username/password authentication, and an optional manual-ACK mode for +end-to-end exactly-once delivery at the application boundary. + +Optimized for [AtomVM](https://atomvm.org/) (an Erlang VM for embedded +devices) but runs unchanged on stock Erlang/OTP. + +## Adding to your project + +### rebar3 + +```erlang +{deps, [ + {amqtt_client, + {git, "https://github.com/atomvm/amqtt.git", + {branch, "main"}}} +]}. +``` + +### Elixir (`mix.exs`) + +```elixir +defp deps do + [ + {:amqtt_client, + git: "https://github.com/atomvm/amqtt.git", + branch: "main", + sparse: "amqtt_client"} + ] +end +``` + +The library lives under the `amqtt_client/` subdirectory of the repo; +`sparse:` (rebar3 git deps automatically use the matching app name) keeps +mix from building the e2e / examples projects. + +## Quick usage + +### Connect (plain TCP) + +Erlang: + +```erlang +{ok, Client} = amqtt_client:connect(#{ + host => "broker.example.com", + port => 1883, + client_id => <<"my_client">>, + keep_alive_seconds => 60 +}), +receive + {mqtt, Client, connack, #{return_code := 0}} -> ok +end. +``` + +Elixir: + +```elixir +{:ok, client} = :amqtt_client.connect(%{ + host: "broker.example.com", + port: 1883, + client_id: "my_client", + keep_alive_seconds: 60 +}) + +receive do + {:mqtt, ^client, :connack, %{return_code: 0}} -> :ok +end +``` + +### Connect (TLS) + +Erlang: + +```erlang +{ok, Client} = amqtt_client:connect(#{ + host => "broker.example.com", + port => 8883, + transport => ssl, + ssl_opts => [{verify, verify_none}], + client_id => <<"my_client">>, + keep_alive_seconds => 60 +}). +``` + +Elixir: + +```elixir +{:ok, client} = :amqtt_client.connect(%{ + host: "broker.example.com", + port: 8883, + transport: :ssl, + ssl_opts: [{:verify, :verify_none}], + client_id: "my_client", + keep_alive_seconds: 60 +}) +``` + +### Subscribe + +Erlang: + +```erlang +{ok, _GrantedQoSs} = amqtt_client:subscribe(Client, [{<<"sensor/#">>, 1}]), +receive + {mqtt, Client, publish, #{topic := Topic, message := Payload}} -> + io:format("~s: ~p~n", [Topic, Payload]) +end. +``` + +Elixir: + +```elixir +{:ok, _granted_qoss} = :amqtt_client.subscribe(client, [{"sensor/#", 1}]) + +receive do + {:mqtt, ^client, :publish, %{topic: topic, message: payload}} -> + IO.puts("#{topic}: #{inspect(payload)}") +end +``` + +### Publish + +Erlang: + +```erlang +%% QoS 0 -- fire and forget +ok = amqtt_client:publish(Client, <<"sensor/temp">>, <<"22.5">>, 0). + +%% QoS 1 -- blocks until PUBACK +{ok, _PacketId} = amqtt_client:publish(Client, <<"sensor/temp">>, <<"22.5">>, 1). + +%% With retain +{ok, _} = amqtt_client:publish(Client, <<"status">>, <<"online">>, 1, + #{retain => true}). +``` + +Elixir: + +```elixir +# QoS 0 -- fire and forget +:ok = :amqtt_client.publish(client, "sensor/temp", "22.5", 0) + +# QoS 1 -- blocks until PUBACK +{:ok, _packet_id} = :amqtt_client.publish(client, "sensor/temp", "22.5", 1) + +# With retain +{:ok, _} = :amqtt_client.publish(client, "status", "online", 1, %{retain: true}) +``` + +> **Note on dispatch.** `publish/4,5` dispatches differently by QoS: +> at QoS 0 it is a `gen_server:cast/2` (fire-and-forget, returns `ok` +> as soon as the cast is queued -- no backpressure, no notification of +> transport failure, a dead client process is silently dropped). At QoS +> 1 / 2 it is a `gen_server:call/3` (synchronous, blocks until PUBACK +> or PUBCOMP, returns `{ok, PacketId}` or `{error, _}`). All the usual +> `gen_server:call` / `gen_server:cast` caveats apply -- in particular +> a dead client raises `noproc` for QoS 1/2 callers. The call uses an +> `infinity` client-side timeout so only the server-side +> `?REQUEST_TIMEOUT` (30 s) fires; the caller gets back +> `{error, timeout}` rather than an exit. + +## Owner messages + +The owner process (caller of `connect/1` unless overridden via the `owner` +connect option) receives these messages from the client. + +Erlang: + +```erlang +%% Broker replied to CONNECT. +{mqtt, Client, connack, #{ + session_present := boolean(), + return_code := 0..5 +}} + +%% Inbound PUBLISH at any QoS. `packet_id' is present only for QoS 1/2. +{mqtt, Client, publish, #{ + topic := binary(), + message := binary(), + qos := 0 | 1 | 2, + dup := boolean(), + retain := boolean(), + packet_id => 1..65535 +}} + +%% Broker closed the connection. +{mqtt, Client, disconnected, #{}} + +%% Transport error, protocol error, or other fatal condition. The +%% client process stops immediately afterwards. +{mqtt, Client, error, #{reason := term()}} +``` + +Elixir: + +```elixir +# Broker replied to CONNECT. +{:mqtt, client, :connack, %{ + session_present: boolean(), + return_code: 0..5 +}} + +# Inbound PUBLISH at any QoS. `packet_id` is present only for QoS 1/2. +{:mqtt, client, :publish, %{ + topic: binary(), + message: binary(), + qos: 0 | 1 | 2, + dup: boolean(), + retain: boolean(), + packet_id: 1..65535 +}} + +# Broker closed the connection. +{:mqtt, client, :disconnected, %{}} + +# Transport error, protocol error, or other fatal condition. The +# client process stops immediately afterwards. +{:mqtt, client, :error, %{reason: term()}} +``` + +By default `PUBACK` / `PUBREC` / `PUBCOMP` are sent automatically by the +client. With `auto_ack => false` at connect time the owner must call +`amqtt_client:ack(Client, PacketId)` after handling the inbound `publish` +event; the QoS 2 `PUBREL` → `PUBCOMP` second leg stays automatic. + +## Examples + +See [`../examples/`](../examples/) for runnable demos (publish, TLS +publish, wildcard subscribe, QoS 2 with persistent session + manual ACK, +JSON subscribe) and [`../e2e/client_pub_sub_e2e/`](../e2e/client_pub_sub_e2e/) +for an end-to-end test you can run on AtomVM or stock OTP. diff --git a/amqtt_client/rebar.config b/amqtt_client/rebar.config new file mode 100644 index 0000000..b42db11 --- /dev/null +++ b/amqtt_client/rebar.config @@ -0,0 +1,49 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. +{deps, []}. + +{project_plugins, [erlfmt, rebar3_hex]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "test/*.{erl,hrl}", + "rebar.config" + ]} +]}. + +{shell, [ + {apps, [amqtt_client]} +]}. + +%% `ssl' is deliberately not in `applications' (AtomVM has no application +%% controller; we call `ssl:start/0' lazily in open_connection/1). Add it +%% here so dialyzer knows about ssl:start/0 and ssl:connect/3. +{dialyzer, [ + {plt_extra_apps, [ssl]} +]}. + +{edoc_opts, [ + {preprocess, true}, + {dir, "doc"} +]}. + +{hex, [{doc, edoc}]}. diff --git a/amqtt_client/rebar.lock b/amqtt_client/rebar.lock new file mode 100644 index 0000000..57afcca --- /dev/null +++ b/amqtt_client/rebar.lock @@ -0,0 +1 @@ +[]. diff --git a/amqtt_client/rebar.lock.license b/amqtt_client/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/amqtt_client/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/amqtt_client/src/amqtt_client.app.src b/amqtt_client/src/amqtt_client.app.src new file mode 100644 index 0000000..d735ec5 --- /dev/null +++ b/amqtt_client/src/amqtt_client.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, amqtt_client, [ + {description, "MQTT 3.1.1 client for AtomVM"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/amqtt_client/src/amqtt_client.erl b/amqtt_client/src/amqtt_client.erl new file mode 100644 index 0000000..8125499 --- /dev/null +++ b/amqtt_client/src/amqtt_client.erl @@ -0,0 +1,879 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +%% @doc MQTT 3.1.1 client process. +%% +%% A client is started with {@link connect/1} or {@link connect/2}; the +%% returned pid receives all subsequent operations. Inbound MQTT events +%% are delivered to the owner process as messages of type +%% {@type mqtt_event()}. +%% +%% == Transports == +%% +%% The `transport' option selects the transport. Built-in choices are +%% the atoms `gen_tcp' (default, plaintext) and `ssl'. A pre-opened +%% socket can also be passed as `{Module, Connection}'; in that case +%% `Module' must export `send/2' and `close/1', and the caller must own +%% the socket so ownership can be transferred to the gen_server. +%% +%% For active-mode transports the gen_server expects messages of the +%% form `{DataTag, Connection, Binary}', `{ClosedTag, Connection}', and +%% `{ErrorTag, Connection, Reason}'. For passive-mode transports +%% (`ssl', or any transport with `active => false') a reader process is +%% spawned that calls `recv/2' in a loop and forwards data as the same +%% messages. Tags default to `{tcp, tcp_closed, tcp_error}' for +%% `gen_tcp' and `{ssl, ssl_closed, ssl_error}' for `ssl', and can be +%% overridden via the `transport_tags' option. +%% +%% == TLS options == +%% +%% No defaults are injected into `ssl_opts': the caller chooses the +%% security posture. On AtomVM `ssl_opts' must include +%% `{verify, verify_none}', as that is the only verification mode the +%% AtomVM `ssl' module currently supports (custom CA certificates and +%% `verify_peer' are not available). On stock OTP, callers can pass +%% `{verify, verify_peer}' together with the appropriate `cacerts'. +%% +%% == Owner messages == +%% +%% The owner (caller of {@link connect/1} unless overridden) receives: +%%
    +%%
  • `{mqtt, Pid, connack, ConnAckData}' once the broker replies to +%% CONNECT.
  • +%%
  • `{mqtt, Pid, publish, PublishData}' for inbound PUBLISH at any +%% QoS. By default PUBACK / PUBREC / PUBCOMP are sent automatically; +%% see "Manual ACK" below.
  • +%%
  • `{mqtt, Pid, disconnected, #{}}' when the broker closes the +%% connection.
  • +%%
  • `{mqtt, Pid, error, #{reason := term()}}' on transport error, +%% protocol error, or other fatal condition. The gen_server stops +%% immediately afterwards.
  • +%%
+%% +%% == Manual ACK == +%% +%% Pass `auto_ack => false' in the connect options to opt into manual +%% acknowledgment for inbound QoS 1 / QoS 2 PUBLISH. The owner receives +%% the `{mqtt, Pid, publish, _}' event as usual, but PUBACK (QoS 1) or +%% PUBREC (QoS 2) is held back until the owner calls +%% {@link ack/2}. The QoS 2 second leg (broker's PUBREL -> our PUBCOMP) +%% remains automatic. This lets owners persist a message durably before +%% telling the broker it's been received: an owner crash between event +%% delivery and persistence will trigger a broker retransmit on +%% reconnect. +%% +%% @end +-module(amqtt_client). +-behaviour(gen_server). + +-export_type([ + connect_opts/0, + transport/0, + transport_tags/0, + mqtt_event/0 +]). + +-type qos() :: amqtt_proto:qos(). +-type packet_id() :: amqtt_proto:packet_id(). +-type topic() :: binary(). + +-type transport_module() :: gen_tcp | ssl | module(). +-type connection() :: term(). +-type transport() :: gen_tcp | ssl | {transport_module(), connection()}. +-type transport_tags() :: {DataTag :: atom(), ClosedTag :: atom(), ErrorTag :: atom()}. + +-type connect_opts() :: #{ + host => string() | binary() | inet:ip_address(), + port => 1..65535, + client_id := binary(), + keep_alive_seconds => 0..65535, + clean_session => boolean(), + username => binary(), + password => binary(), + will_topic => binary(), + will_message => binary(), + will_qos => qos(), + will_retain => boolean(), + transport => transport(), + transport_tags => transport_tags(), + ssl_opts => [term()], + active => boolean(), + owner => pid(), + auto_ack => boolean() +}. + +-type mqtt_event() :: + {mqtt, pid(), connack, amqtt_proto:connack_data()} + | {mqtt, pid(), publish, amqtt_proto:publish_data()} + | {mqtt, pid(), disconnected, #{}} + | {mqtt, pid(), error, #{reason := term()}}. + +-export([ + connect/1, + connect/2, + publish/4, + publish/5, + subscribe/2, + unsubscribe/2, + disconnect/1, + ping/1, + ack/2 +]). + +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2 +]). + +-define(REQUEST_TIMEOUT, 30000). +-define(MAX_BUFFER_SIZE, (1 bsl 18)). + +-type pending_ack_type() :: puback | pubrec | pubcomp | suback | unsuback. +-type pending_entry() :: {pending_ack_type(), gen_server:from(), reference()}. +-type inbound_phase() :: awaiting_ack | pubrec_sent. +-type inbound_entry() :: {inbound_phase(), qos(), amqtt_proto:publish_data()}. + +-record(state, { + transport :: transport_module() | undefined, + connection :: connection() | undefined, + transport_tags :: transport_tags() | undefined, + reader :: pid() | undefined, + pending_connect_opts :: connect_opts() | undefined, + buffer = <<>> :: binary(), + next_packet_id = 1 :: packet_id(), + pending = #{} :: #{packet_id() => pending_entry()}, + inbound_qos2 = #{} :: #{packet_id() => true}, + auto_ack = true :: boolean(), + inbound_pending_ack = #{} :: #{packet_id() => inbound_entry()}, + owner :: pid() | undefined, + owner_monitor :: reference() | undefined, + keep_alive_seconds :: 0..65535 | undefined, + ping_timer :: reference() | undefined, + pingresp_timer :: reference() | undefined, + connected = false :: boolean() +}). + +%% ------------------------------------------------------------------- +%% Public API +%% ------------------------------------------------------------------- + +%% @equiv connect(Opts, []) +-spec connect(connect_opts()) -> {ok, pid()} | {error, term()}. +connect(Opts) -> + connect(Opts, []). + +%% @doc Start an MQTT client and send the CONNECT packet. +%% +%% Blocks until CONNECT has been written to the transport, then returns +%% `{ok, Pid}'. The CONNACK is delivered asynchronously as a +%% `{mqtt, Pid, connack, _}' message to the owner; callers should wait +%% for it before issuing {@link publish/4}, {@link subscribe/2}, or +%% {@link unsubscribe/2}, all of which return `{error, not_connected}' +%% if invoked beforehand. +%% +%% `Opts' is a {@type connect_opts()} map; `client_id' is required. +%% `GenServerOpts' is forwarded to `gen_server:start_link/3'. +%% +%% When `transport => ssl', `ssl_opts' is passed through to +%% `ssl:connect/3' as-is. On AtomVM it must include +%% `{verify, verify_none}'; see the module-level "TLS options" section. +%% +%% @param Opts MQTT and transport options. +%% @param GenServerOpts options for `gen_server:start_link/3'. +%% @returns `{ok, Pid}' on success, `{error, Reason}' on transport or +%% CONNECT-send failure. +%% @end +-spec connect(connect_opts(), list()) -> {ok, pid()} | {error, term()}. +connect(Opts, GenServerOpts) -> + DefaultTags = + case maps:get(transport, Opts, gen_tcp) of + ssl -> {ssl, ssl_closed, ssl_error}; + {ssl, _} -> {ssl, ssl_closed, ssl_error}; + _ -> {tcp, tcp_closed, tcp_error} + end, + Opts1 = Opts#{ + owner => maps:get(owner, Opts, self()), + transport_tags => maps:get(transport_tags, Opts, DefaultTags) + }, + case gen_server:start_link(?MODULE, Opts1, GenServerOpts) of + {ok, Pid} -> + %% Ownership of a pre-opened socket must transfer before CONNECT + %% goes out, otherwise active-mode messages reach the caller. + case transfer_ownership(Opts1, Pid) of + ok -> + case gen_server:call(Pid, send_connect_packet) of + ok -> + {ok, Pid}; + {error, _} = Err -> + try + gen_server:stop(Pid) + catch + _:_ -> ok + end, + Err + end; + {error, _} = Err -> + try + gen_server:stop(Pid) + catch + _:_ -> ok + end, + Err + end; + Error -> + Error + end. + +transfer_ownership(#{transport := {Module, Connection}}, Pid) when not is_atom(Connection) -> + case erlang:function_exported(Module, controlling_process, 2) of + true -> Module:controlling_process(Connection, Pid); + false -> ok + end; +transfer_ownership(_, _) -> + ok. + +%% @equiv publish(Pid, Topic, Message, QoS, #{}) +-spec publish(pid(), topic(), iodata(), qos()) -> ok | {ok, packet_id()} | {error, term()}. +publish(Pid, Topic, Message, QoS) -> + publish(Pid, Topic, Message, QoS, #{}). + +%% @doc Publish a message to `Topic'. +%% +%% Dispatch differs by QoS: +%%
    +%%
  • QoS 0 -- fire-and-forget via `gen_server:cast/2'. Returns `ok' +%% as soon as the cast is queued, regardless of whether the +%% client process or the underlying transport actually succeeds. +%% Cast caveats apply: a dead client process is silently dropped, +%% there is no backpressure, and the caller does not learn about +%% transport failure here.
  • +%%
  • QoS 1 / 2 -- synchronous `gen_server:call/3' (with `infinity' +%% client-side timeout, so only the server-side `?REQUEST_TIMEOUT' +%% can fire). At QoS 1 it blocks until PUBACK; at QoS 2 it blocks +%% through PUBREC/PUBREL/PUBCOMP. Returns `{ok, PacketId}' on +%% success, `{error, timeout}' if the broker never acks, or other +%% `{error, _}' tuples on transport / protocol failure. Standard +%% `gen_server:call' caveats apply: a dead client process raises +%% `noproc' / `timeout' / similar in the caller.
  • +%%
+%% +%% `Opts' may contain `retain => boolean()' and `dup => boolean()'. +%% +%% @param Pid client returned by {@link connect/1}. +%% @param Topic publish topic. +%% @param Message payload. +%% @param QoS 0, 1, or 2. +%% @param Opts extra encoding flags. +%% @returns `ok' for QoS 0; `{ok, PacketId}' for QoS 1 or 2; `{error, +%% Reason}' on failure (`not_connected', `timeout', `no_packet_ids', or +%% a transport error). +%% @end +-spec publish(pid(), topic(), iodata(), qos(), map()) -> ok | {ok, packet_id()} | {error, term()}. +publish(Pid, Topic, Message, 0, Opts) -> + gen_server:cast(Pid, {publish, Topic, Message, 0, Opts}), + ok; +publish(Pid, Topic, Message, QoS, Opts) -> + %% `infinity`: the server arms ?REQUEST_TIMEOUT and replies + %% `{error, timeout}`; a finite call timeout would race it. + gen_server:call(Pid, {publish, Topic, Message, QoS, Opts}, infinity). + +%% @doc Subscribe to one or more topic filters. +%% +%% Blocks until the broker replies with SUBACK and returns the per-topic +%% return codes: each is the granted QoS (`0'..`2') or `16#80' if the +%% subscription was refused. +%% +%% @param Pid client returned by {@link connect/1}. +%% @param Topics list of `{TopicFilter, RequestedQoS}' pairs. +%% @returns `{ok, ReturnCodes}' or `{error, Reason}'. +%% @end +-spec subscribe(pid(), [{topic(), qos()}]) -> {ok, [qos() | 128]} | {error, term()}. +subscribe(Pid, Topics) -> + gen_server:call(Pid, {subscribe, Topics}, infinity). + +%% @doc Unsubscribe from one or more topic filters. +%% +%% Blocks until the broker replies with UNSUBACK. +%% +%% @param Pid client returned by {@link connect/1}. +%% @param Topics list of topic filters to remove. +%% @returns `ok' or `{error, Reason}'. +%% @end +-spec unsubscribe(pid(), [topic()]) -> ok | {error, term()}. +unsubscribe(Pid, Topics) -> + gen_server:call(Pid, {unsubscribe, Topics}, infinity). + +%% @doc Send DISCONNECT and stop the client. +%% +%% Best-effort: a transport error during the send is ignored; the socket +%% is closed and the gen_server terminates with reason `normal' either +%% way. Allowed before CONNACK as well. +%% +%% @param Pid client returned by {@link connect/1}. +%% @end +-spec disconnect(pid()) -> ok. +disconnect(Pid) -> + gen_server:call(Pid, disconnect). + +%% @doc Send a manual PINGREQ. +%% +%% The keep-alive timer already pings automatically; this is for +%% applications that want to probe the link explicitly. Asynchronous: +%% the PINGRESP is consumed silently by the gen_server. +%% +%% @param Pid client returned by {@link connect/1}. +%% @end +-spec ping(pid()) -> ok. +ping(Pid) -> + gen_server:cast(Pid, ping). + +%% @doc Acknowledge an inbound QoS 1 or QoS 2 PUBLISH. +%% +%% Only meaningful when the client was started with `auto_ack => false'; +%% sends PUBACK (QoS 1) or PUBREC (QoS 2). For QoS 2 the broker's +%% PUBREL -> PUBCOMP second leg is auto-handled. +%% +%% Idempotent: a second `ack/2' for a packet whose PUBREC has already +%% been sent returns `ok'. Returns `{error, not_pending}' if the +%% packet_id was never received or has already been fully completed, +%% and `{error, auto_ack_enabled}' if called on an `auto_ack => true' +%% client. +%% +%% @param Pid client returned by {@link connect/1}. +%% @param PacketId the packet identifier from the inbound `publish' event. +%% @end +-spec ack(pid(), packet_id()) -> ok | {error, not_pending | auto_ack_enabled}. +ack(Pid, PacketId) -> + gen_server:call(Pid, {ack, PacketId}, infinity). + +%% ------------------------------------------------------------------- +%% gen_server callbacks +%% ------------------------------------------------------------------- + +init(Opts) -> + Owner = maps:get(owner, Opts), + KeepAliveSeconds = maps:get(keep_alive_seconds, Opts, 60), + Tags = maps:get(transport_tags, Opts), + AutoAck = maps:get(auto_ack, Opts, true), + OwnerRef = erlang:monitor(process, Owner), + case open_connection(Opts) of + {ok, Transport, Connection} -> + Reader = maybe_start_reader(Opts, Transport, Connection, Tags), + State = #state{ + transport = Transport, + connection = Connection, + transport_tags = Tags, + reader = Reader, + pending_connect_opts = Opts, + owner = Owner, + owner_monitor = OwnerRef, + keep_alive_seconds = KeepAliveSeconds, + auto_ack = AutoAck + }, + {ok, State}; + {error, Reason} -> + erlang:demonitor(OwnerRef, [flush]), + {stop, {connect_error, Reason}} + end. + +handle_call(send_connect_packet, _From, #state{pending_connect_opts = Opts} = State) -> + %% Drop credentials from state before the send; a failure must not leave + %% them lingering. + State1 = State#state{pending_connect_opts = undefined}, + Packet = amqtt_proto:encode_connect(Opts), + case send_packet(Packet, State1) of + {ok, State2} -> + {reply, ok, State2}; + {error, Reason, State2} -> + %% Stop normal, not abnormal: we are linked to the caller via + %% start_link, and an abnormal exit would propagate a link signal + %% that kills a non-trap_exit caller before it can observe the + %% {error, _} reply. + close_connection(State2), + {stop, normal, {error, Reason}, State2#state{connection = undefined}} + end; +handle_call(disconnect, _From, State) -> + _ = raw_send(amqtt_proto:encode_disconnect(), State), + close_connection(State), + {stop, normal, ok, cancel_all_timers(State#state{connection = undefined, connected = false})}; +handle_call(_Req, _From, #state{connected = false} = State) -> + {reply, {error, not_connected}, State}; +handle_call({publish, Topic, Message, QoS, Opts}, From, State) -> + case alloc_packet_id(State) of + {ok, PacketId, State1} -> + Packet = amqtt_proto:encode_publish(Opts#{ + topic => Topic, + message => Message, + qos => QoS, + packet_id => PacketId + }), + AckType = + case QoS of + 1 -> puback; + 2 -> pubrec + end, + send_and_pend(Packet, PacketId, AckType, From, State1); + {error, Reason} -> + {reply, {error, Reason}, State} + end; +handle_call({subscribe, Topics}, From, State) -> + case alloc_packet_id(State) of + {ok, PacketId, State1} -> + Packet = amqtt_proto:encode_subscribe(#{packet_id => PacketId, topics => Topics}), + send_and_pend(Packet, PacketId, suback, From, State1); + {error, Reason} -> + {reply, {error, Reason}, State} + end; +handle_call({unsubscribe, Topics}, From, State) -> + case alloc_packet_id(State) of + {ok, PacketId, State1} -> + Packet = amqtt_proto:encode_unsubscribe(#{packet_id => PacketId, topics => Topics}), + send_and_pend(Packet, PacketId, unsuback, From, State1); + {error, Reason} -> + {reply, {error, Reason}, State} + end; +handle_call({ack, _PId}, _From, #state{auto_ack = true} = State) -> + {reply, {error, auto_ack_enabled}, State}; +handle_call({ack, PacketId}, _From, #state{inbound_pending_ack = Pending} = State) -> + case maps:find(PacketId, Pending) of + {ok, {awaiting_ack, 1, _Data}} -> + case send_packet(amqtt_proto:encode_puback(PacketId), State) of + {ok, State1} -> + {reply, ok, State1#state{ + inbound_pending_ack = maps:remove(PacketId, Pending) + }}; + {error, Reason, State1} -> + {reply, {error, Reason}, State1} + end; + {ok, {awaiting_ack, 2, Data}} -> + case send_packet(amqtt_proto:encode_pubrec(PacketId), State) of + {ok, State1} -> + {reply, ok, State1#state{ + inbound_pending_ack = Pending#{PacketId => {pubrec_sent, 2, Data}} + }}; + {error, Reason, State1} -> + {reply, {error, Reason}, State1} + end; + {ok, {pubrec_sent, 2, _Data}} -> + %% Idempotent: PUBREC already sent; do not retransmit on a + %% re-ack from a restarted owner. + {reply, ok, State}; + error -> + {reply, {error, not_pending}, State} + end. + +handle_cast(_Req, #state{connected = false} = State) -> + {noreply, State}; +handle_cast({publish, Topic, Message, 0, Opts}, State) -> + Packet = amqtt_proto:encode_publish(Opts#{topic => Topic, message => Message, qos => 0}), + case send_packet(Packet, State) of + {ok, State1} -> {noreply, State1}; + {error, Reason, State1} -> stop_with_error(Reason, State1) + end; +handle_cast(ping, State) -> + case send_packet(amqtt_proto:encode_pingreq(), State) of + {ok, State1} -> {noreply, arm_pingresp_timer(State1)}; + {error, Reason, State1} -> stop_with_error(Reason, State1) + end. + +handle_info( + {DataTag, _Sock, Data}, + #state{ + transport_tags = {DataTag, _, _}, + buffer = Buffer + } = State +) -> + NewBuffer = <>, + case byte_size(NewBuffer) > ?MAX_BUFFER_SIZE of + true -> + stop_with_error(buffer_overflow, State#state{buffer = NewBuffer}); + false -> + case process_buffer(State#state{buffer = NewBuffer}) of + {ok, State1} -> + {noreply, State1}; + {stop_normal, State1} -> + close_connection(State1), + {stop, normal, + cancel_all_timers(State1#state{connection = undefined, connected = false})}; + {error, Reason, State1} -> + stop_with_error(Reason, State1) + end + end; +handle_info( + {ClosedTag, _Sock}, + #state{ + transport_tags = {_, ClosedTag, _}, + owner = Owner + } = State +) -> + Owner ! {mqtt, self(), disconnected, #{}}, + {stop, normal, cancel_all_timers(State#state{connection = undefined, connected = false})}; +handle_info( + {ErrorTag, _Sock, Reason}, + #state{ + transport_tags = {_, _, ErrorTag} + } = State +) -> + stop_with_error(Reason, State); +handle_info(send_ping, State) -> + case send_packet(amqtt_proto:encode_pingreq(), State#state{ping_timer = undefined}) of + {ok, State1} -> {noreply, arm_pingresp_timer(State1)}; + {error, Reason, State1} -> stop_with_error(Reason, State1) + end; +handle_info(pingresp_timeout, State) -> + stop_with_error(pingresp_timeout, State#state{pingresp_timer = undefined}); +handle_info({request_timeout, PacketId}, State) -> + case maps:get(PacketId, State#state.pending, undefined) of + {_AckType, From, _TRef} -> + gen_server:reply(From, {error, timeout}), + {noreply, State#state{pending = maps:remove(PacketId, State#state.pending)}}; + _ -> + {noreply, State} + end; +handle_info( + {'DOWN', Ref, process, _Owner, _Reason}, + #state{owner_monitor = Ref} = State +) -> + _ = raw_send(amqtt_proto:encode_disconnect(), State), + close_connection(State), + {stop, normal, cancel_all_timers(State#state{connection = undefined, connected = false})}; +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, #state{connection = undefined}) -> + ok; +terminate(_Reason, State) -> + close_connection(State), + ok. + +%% ------------------------------------------------------------------- +%% Internal: Connection Setup +%% ------------------------------------------------------------------- + +open_connection(#{transport := ssl} = Opts) -> + Host = maps:get(host, Opts), + Port = maps:get(port, Opts, 8883), + SslOpts = maps:get(ssl_opts, Opts, []), + ssl:start(), + case ssl:connect(Host, Port, [{active, false}, binary | SslOpts]) of + {ok, SSLSocket} -> {ok, ssl, SSLSocket}; + {error, _} = Err -> Err + end; +open_connection(#{transport := {Module, Connection}}) -> + {ok, Module, Connection}; +open_connection(Opts) -> + Host = maps:get(host, Opts), + Port = maps:get(port, Opts, 1883), + Active = maps:get(active, Opts, true), + case gen_tcp:connect(Host, Port, [{active, Active}, binary]) of + {ok, Socket} -> {ok, gen_tcp, Socket}; + {error, _} = Err -> Err + end. + +close_connection(#state{connection = undefined}) -> + ok; +close_connection(#state{transport = T, connection = C}) -> + try + T:close(C) + catch + _:_ -> ok + end, + ok. + +%% ------------------------------------------------------------------- +%% Internal: Reader Process for Passive-Mode Transports +%% ------------------------------------------------------------------- + +needs_reader(#{transport := ssl}) -> true; +needs_reader(#{transport := {ssl, _}}) -> true; +needs_reader(#{active := false}) -> true; +needs_reader(_) -> false. + +maybe_start_reader(Opts, Transport, Connection, {DataTag, ClosedTag, ErrorTag}) -> + case needs_reader(Opts) of + true -> + Self = self(), + spawn_link(fun() -> + reader_loop(Transport, Connection, Self, DataTag, ClosedTag, ErrorTag) + end); + false -> + undefined + end. + +reader_loop(Transport, Connection, Owner, DataTag, ClosedTag, ErrorTag) -> + case Transport:recv(Connection, 0) of + {ok, Data} -> + Owner ! {DataTag, Connection, Data}, + reader_loop(Transport, Connection, Owner, DataTag, ClosedTag, ErrorTag); + {error, closed} -> + Owner ! {ClosedTag, Connection}; + {error, Reason} -> + Owner ! {ErrorTag, Connection, Reason} + end. + +%% ------------------------------------------------------------------- +%% Internal: Send helpers +%% ------------------------------------------------------------------- + +raw_send(_Packet, #state{connection = undefined}) -> + {error, closed}; +raw_send(Packet, #state{transport = T, connection = C}) -> + T:send(C, Packet). + +send_packet(Packet, State) -> + case raw_send(Packet, State) of + ok -> {ok, restart_ping_timer(State)}; + {error, Reason} -> {error, Reason, State} + end. + +send_and_pend(Packet, PacketId, AckType, From, State) -> + case send_packet(Packet, State) of + {ok, State1} -> + TRef = erlang:send_after(?REQUEST_TIMEOUT, self(), {request_timeout, PacketId}), + Pending = maps:put(PacketId, {AckType, From, TRef}, State1#state.pending), + {noreply, State1#state{pending = Pending}}; + {error, Reason, State1} -> + gen_server:reply(From, {error, Reason}), + stop_with_error(Reason, State1) + end. + +stop_with_error(Reason, #state{owner = Owner} = State) -> + Owner ! {mqtt, self(), error, #{reason => Reason}}, + State1 = reply_pending_with_error(Reason, State), + close_connection(State1), + {stop, {transport_error, Reason}, + cancel_all_timers(State1#state{connection = undefined, connected = false})}. + +reply_pending_with_error(Reason, #state{pending = Pending} = State) -> + maps:foreach( + fun(_PId, {_AckType, From, TRef}) -> + erlang:cancel_timer(TRef), + gen_server:reply(From, {error, Reason}) + end, + Pending + ), + State#state{pending = #{}}. + +%% ------------------------------------------------------------------- +%% Internal: Packet ID allocation +%% ------------------------------------------------------------------- + +alloc_packet_id(State) -> + alloc_packet_id(State, 0). + +alloc_packet_id(_State, N) when N >= 65535 -> + {error, no_packet_ids}; +alloc_packet_id(#state{next_packet_id = Id, pending = Pending} = State, N) -> + Next = (Id rem 65535) + 1, + case maps:is_key(Id, Pending) of + true -> + alloc_packet_id(State#state{next_packet_id = Next}, N + 1); + false -> + {ok, Id, State#state{next_packet_id = Next}} + end. + +%% ------------------------------------------------------------------- +%% Internal: Inbound packet processing +%% ------------------------------------------------------------------- + +process_buffer(#state{buffer = Buffer} = State) -> + case amqtt_proto:decode(Buffer) of + {ok, {Type, Data}, Rest} -> + case handle_packet(Type, Data, State#state{buffer = Rest}) of + {ok, State1} -> process_buffer(State1); + {stop_normal, State1} -> {stop_normal, State1}; + {error, _, _} = Err -> Err + end; + {error, incomplete} -> + {ok, State}; + {error, {protocol_error, Reason}} -> + {error, {protocol_error, Reason}, State} + end. + +handle_packet(connack, #{return_code := 0} = Data, #state{owner = Owner} = State) -> + Owner ! {mqtt, self(), connack, Data}, + {ok, State#state{connected = true}}; +handle_packet(connack, Data, #state{owner = Owner} = State) -> + %% MQTT 3.1.1 §3.2: a non-zero return code requires the broker to close. + Owner ! {mqtt, self(), connack, Data}, + {stop_normal, State#state{connected = false}}; +handle_packet(publish, #{qos := 0} = Data, #state{owner = Owner} = State) -> + Owner ! {mqtt, self(), publish, Data}, + {ok, State}; +handle_packet( + publish, + #{qos := 1, packet_id := PacketId} = Data, + #state{owner = Owner, auto_ack = true} = State +) -> + Owner ! {mqtt, self(), publish, Data}, + case send_packet(amqtt_proto:encode_puback(PacketId), State) of + {ok, State1} -> {ok, State1}; + {error, Reason, State1} -> {error, Reason, State1} + end; +handle_packet( + publish, + #{qos := 1, packet_id := PacketId} = Data, + #state{owner = Owner, inbound_pending_ack = Pending} = State +) -> + %% Manual-ack: hold PUBACK until the owner calls ack/2. Idempotent on + %% duplicate broker retransmits before the owner has acked, only the + %% first delivery raises an event; PUBACK is still held back. + case maps:is_key(PacketId, Pending) of + true -> + {ok, State}; + false -> + Owner ! {mqtt, self(), publish, Data}, + {ok, State#state{ + inbound_pending_ack = Pending#{PacketId => {awaiting_ack, 1, Data}} + }} + end; +handle_packet( + publish, + #{qos := 2, packet_id := PacketId} = Data, + #state{owner = Owner, inbound_qos2 = Set, auto_ack = true} = State +) -> + %% Deliver to the owner only the first time; PUBREC is sent in either + %% case so the broker stops retransmitting. + State1 = + case maps:is_key(PacketId, Set) of + true -> + State; + false -> + Owner ! {mqtt, self(), publish, Data}, + State#state{inbound_qos2 = Set#{PacketId => true}} + end, + case send_packet(amqtt_proto:encode_pubrec(PacketId), State1) of + {ok, State2} -> {ok, State2}; + {error, Reason, State2} -> {error, Reason, State2} + end; +handle_packet( + publish, + #{qos := 2, packet_id := PacketId} = Data, + #state{ + owner = Owner, + inbound_qos2 = Set, + inbound_pending_ack = Pending + } = State +) -> + %% Manual-ack: deliver once (dedup via inbound_qos2). PUBREC is held + %% back until the owner calls ack/2, except on a duplicate retransmit + %% after the owner has already acked, where we resend PUBREC since + %% the broker clearly hasn't seen it. + case maps:find(PacketId, Pending) of + error -> + Owner ! {mqtt, self(), publish, Data}, + {ok, State#state{ + inbound_qos2 = Set#{PacketId => true}, + inbound_pending_ack = Pending#{PacketId => {awaiting_ack, 2, Data}} + }}; + {ok, {awaiting_ack, 2, _}} -> + {ok, State}; + {ok, {pubrec_sent, 2, _}} -> + case send_packet(amqtt_proto:encode_pubrec(PacketId), State) of + {ok, State1} -> {ok, State1}; + {error, Reason, State1} -> {error, Reason, State1} + end + end; +handle_packet(puback, #{packet_id := PacketId}, State) -> + {ok, complete_pending(PacketId, puback, {ok, PacketId}, State)}; +handle_packet(pubrec, #{packet_id := PacketId}, State) -> + case send_packet(amqtt_proto:encode_pubrel(PacketId), State) of + {ok, State1} -> + case maps:get(PacketId, State1#state.pending, undefined) of + {pubrec, From, TRef} -> + Pending = maps:put(PacketId, {pubcomp, From, TRef}, State1#state.pending), + {ok, State1#state{pending = Pending}}; + _ -> + {ok, State1} + end; + {error, Reason, State1} -> + {error, Reason, State1} + end; +handle_packet( + pubrel, + #{packet_id := PacketId}, + #state{inbound_qos2 = Set, inbound_pending_ack = Pending} = State +) -> + State1 = State#state{ + inbound_qos2 = maps:remove(PacketId, Set), + inbound_pending_ack = maps:remove(PacketId, Pending) + }, + case send_packet(amqtt_proto:encode_pubcomp(PacketId), State1) of + {ok, State2} -> {ok, State2}; + {error, Reason, State2} -> {error, Reason, State2} + end; +handle_packet(pubcomp, #{packet_id := PacketId}, State) -> + {ok, complete_pending(PacketId, pubcomp, {ok, PacketId}, State)}; +handle_packet(suback, #{packet_id := PacketId, return_codes := RCs}, State) -> + {ok, complete_pending(PacketId, suback, {ok, RCs}, State)}; +handle_packet(unsuback, #{packet_id := PacketId}, State) -> + {ok, complete_pending(PacketId, unsuback, ok, State)}; +handle_packet(pingresp, _Data, State) -> + {ok, cancel_pingresp_timer(State)}; +handle_packet(_Type, _Data, State) -> + {ok, State}. + +complete_pending(PacketId, ExpectedAckType, Reply, #state{pending = Pending} = State) -> + case maps:get(PacketId, Pending, undefined) of + {ExpectedAckType, From, TRef} -> + erlang:cancel_timer(TRef), + gen_server:reply(From, Reply), + State#state{pending = maps:remove(PacketId, Pending)}; + _ -> + State + end. + +%% ------------------------------------------------------------------- +%% Internal: Timers +%% ------------------------------------------------------------------- + +restart_ping_timer(#state{keep_alive_seconds = 0} = State) -> + State; +restart_ping_timer(State) -> + State1 = cancel_ping_timer(State), + Interval = State1#state.keep_alive_seconds * 750, + Ref = erlang:send_after(Interval, self(), send_ping), + State1#state{ping_timer = Ref}. + +cancel_ping_timer(#state{ping_timer = undefined} = State) -> + State; +cancel_ping_timer(#state{ping_timer = Ref} = State) -> + erlang:cancel_timer(Ref), + State#state{ping_timer = undefined}. + +arm_pingresp_timer(#state{keep_alive_seconds = 0} = State) -> + State; +arm_pingresp_timer(#state{pingresp_timer = OldRef, keep_alive_seconds = KA} = State) -> + case OldRef of + undefined -> ok; + _ -> erlang:cancel_timer(OldRef) + end, + Ref = erlang:send_after(KA * 1000, self(), pingresp_timeout), + State#state{pingresp_timer = Ref}. + +cancel_pingresp_timer(#state{pingresp_timer = undefined} = State) -> + State; +cancel_pingresp_timer(#state{pingresp_timer = Ref} = State) -> + erlang:cancel_timer(Ref), + State#state{pingresp_timer = undefined}. + +cancel_all_timers(State) -> + cancel_pingresp_timer(cancel_ping_timer(State)). diff --git a/amqtt_client/src/amqtt_proto.erl b/amqtt_client/src/amqtt_proto.erl new file mode 100644 index 0000000..fccfd74 --- /dev/null +++ b/amqtt_client/src/amqtt_proto.erl @@ -0,0 +1,650 @@ +% +% Copyright 2020-2026 Davide Bettio +% +% 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 +% + +%% @doc MQTT 3.1.1 wire-format codec. +%% +%% Pure encode/decode functions for the fourteen MQTT 3.1.1 control +%% packet types. No I/O, no process state, suitable for use with any +%% transport. Encoders take a map of fields and return an `iodata'-ready +%% binary; {@link decode/1} consumes a binary buffer and returns the +%% next packet plus any trailing bytes, signalling `incomplete' when +%% more data is needed and `{protocol_error, _}' on a malformed frame. +%% +%% @end +-module(amqtt_proto). + +-export_type([ + qos/0, + packet_id/0, + packet_type/0, + connect_opts/0, + publish_opts/0, + subscribe_opts/0, + unsubscribe_opts/0, + connack_data/0, + publish_data/0, + suback_data/0, + packet_id_data/0, + decoded_packet/0 +]). + +-type qos() :: 0 | 1 | 2. +-type packet_id() :: 1..65535. +-type topic() :: binary(). + +-type packet_type() :: + connect + | connack + | publish + | puback + | pubrec + | pubrel + | pubcomp + | subscribe + | suback + | unsubscribe + | unsuback + | pingreq + | pingresp + | disconnect. + +-type connect_opts() :: #{ + client_id := binary(), + keep_alive_seconds => 0..65535, + clean_session => boolean(), + username => binary(), + password => binary(), + will_topic => binary(), + will_message => binary(), + will_qos => qos(), + will_retain => boolean() +}. + +-type publish_opts() :: #{ + topic := topic(), + message := iodata(), + qos => qos(), + retain => boolean(), + dup => boolean(), + packet_id => packet_id() +}. + +-type subscribe_opts() :: #{ + packet_id := packet_id(), + topics := [{topic(), qos()}] +}. + +-type unsubscribe_opts() :: #{ + packet_id := packet_id(), + topics := [topic()] +}. + +-type connack_data() :: #{ + session_present := boolean(), + return_code := 0..5 +}. + +-type publish_data() :: #{ + topic := topic(), + message := binary(), + qos := qos(), + dup := boolean(), + retain := boolean(), + packet_id => packet_id() +}. + +-type suback_data() :: #{ + packet_id := packet_id(), + return_codes := [qos() | 128] +}. + +-type packet_id_data() :: #{packet_id := packet_id()}. + +-type decoded_packet() :: + {connack, connack_data()} + | {publish, publish_data()} + | {puback, packet_id_data()} + | {pubrec, packet_id_data()} + | {pubrel, packet_id_data()} + | {pubcomp, packet_id_data()} + | {suback, suback_data()} + | {unsuback, packet_id_data()} + | {pingreq, #{}} + | {pingresp, #{}} + | {disconnect, #{}} + | {connect, map()} + | {subscribe, map()} + | {unsubscribe, map()}. + +-export([ + encode_connect/1, + encode_publish/1, + encode_puback/1, + encode_pubrec/1, + encode_pubrel/1, + encode_pubcomp/1, + encode_subscribe/1, + encode_unsubscribe/1, + encode_pingreq/0, + encode_disconnect/0, + decode/1, + encode_remaining_length/1, + decode_remaining_length/1 +]). + +%% Packet types +-define(CONNECT, 1). +-define(CONNACK, 2). +-define(PUBLISH, 3). +-define(PUBACK, 4). +-define(PUBREC, 5). +-define(PUBREL, 6). +-define(PUBCOMP, 7). +-define(SUBSCRIBE, 8). +-define(SUBACK, 9). +-define(UNSUBSCRIBE, 10). +-define(UNSUBACK, 11). +-define(PINGREQ, 12). +-define(PINGRESP, 13). +-define(DISCONNECT, 14). + +%% ------------------------------------------------------------------- +%% Remaining Length Codec +%% ------------------------------------------------------------------- + +%% @doc Encode an MQTT variable-length integer (1 to 4 bytes). +%% +%% Used for the remaining-length field in every fixed header. `N' must +%% be in `0..268435455' (the MQTT 3.1.1 maximum). +%% @end +-spec encode_remaining_length(non_neg_integer()) -> binary(). +encode_remaining_length(N) when N >= 0, N =< 268435455 -> + encode_remaining_length_acc(N, <<>>). + +encode_remaining_length_acc(N, Acc) when N < 128 -> + <>; +encode_remaining_length_acc(N, Acc) -> + encode_remaining_length_acc(N bsr 7, <>). + +%% @doc Decode an MQTT variable-length integer. +%% +%% Returns `{ok, Value, Rest}' on success, `{error, incomplete}' if +%% more bytes are needed, or `{error, invalid}' if the encoding exceeds +%% four bytes. +%% @end +-spec decode_remaining_length(binary()) -> + {ok, non_neg_integer(), binary()} | {error, incomplete | invalid}. +decode_remaining_length(Bin) -> + decode_remaining_length(Bin, 0, 1). + +decode_remaining_length(<<>>, _Value, _Multiplier) -> + {error, incomplete}; +decode_remaining_length(<<0:1, V:7, Rest/binary>>, Value, Multiplier) when Multiplier =< 2097152 -> + {ok, Value + V * Multiplier, Rest}; +decode_remaining_length(<<1:1, V:7, Rest/binary>>, Value, Multiplier) when Multiplier =< 2097152 -> + decode_remaining_length(Rest, Value + V * Multiplier, Multiplier bsl 7); +decode_remaining_length(_Bin, _Value, _Multiplier) -> + {error, invalid}. + +%% ------------------------------------------------------------------- +%% UTF-8 String Helpers +%% ------------------------------------------------------------------- + +encode_utf8(Bin) when is_binary(Bin) -> + Len = byte_size(Bin), + <>. + +decode_utf8(<>) when byte_size(Rest) >= Len -> + <> = Rest, + {ok, Str, Remaining}; +decode_utf8(_) -> + {error, incomplete}. + +%% ------------------------------------------------------------------- +%% Encode Functions +%% ------------------------------------------------------------------- + +%% @doc Encode a CONNECT packet. +%% +%% `Opts' must include `client_id'; all other fields are optional and +%% take their MQTT 3.1.1 defaults (`keep_alive_seconds => 60', +%% `clean_session => true', no will, no credentials). +%% @end +-spec encode_connect(connect_opts()) -> iodata(). +encode_connect(Opts) -> + ClientId = maps:get(client_id, Opts), + KeepAliveSeconds = maps:get(keep_alive_seconds, Opts, 60), + CleanSession = maps:get(clean_session, Opts, true), + Username = maps:get(username, Opts, undefined), + Password = maps:get(password, Opts, undefined), + WillTopic = maps:get(will_topic, Opts, undefined), + WillMessage = maps:get(will_message, Opts, undefined), + WillQoS = maps:get(will_qos, Opts, 0), + WillRetain = maps:get(will_retain, Opts, false), + + CleanSessionBit = bool_to_bit(CleanSession), + HasWill = WillTopic =/= undefined, + WillFlag = bool_to_bit(HasWill), + WillRetainBit = + case HasWill of + true -> bool_to_bit(WillRetain); + false -> 0 + end, + WillQoSBits = + case HasWill of + true -> WillQoS; + false -> 0 + end, + HasUsername = Username =/= undefined, + UsernameBit = bool_to_bit(HasUsername), + HasPassword = Password =/= undefined, + PasswordBit = bool_to_bit(HasPassword), + + VarHeader = + <<0, 4, "MQTT", 4, UsernameBit:1, PasswordBit:1, WillRetainBit:1, WillQoSBits:2, WillFlag:1, + CleanSessionBit:1, 0:1, KeepAliveSeconds:16/big>>, + + WillIo = + case HasWill of + true -> [encode_utf8(WillTopic), encode_utf8(WillMessage)]; + false -> [] + end, + UserIo = + case HasUsername of + true -> encode_utf8(Username); + false -> [] + end, + PassIo = + case HasPassword of + true -> encode_utf8(Password); + false -> [] + end, + + Body = [VarHeader, encode_utf8(ClientId), WillIo, UserIo, PassIo], + RemainingLen = encode_remaining_length(iolist_size(Body)), + [<>, RemainingLen, Body]. + +%% @doc Encode a PUBLISH packet. +%% +%% `topic' and `message' are required. `qos' defaults to `0'; for QoS 1 +%% or 2 a `packet_id' is required. `retain' and `dup' default to +%% `false'. +%% @end +-spec encode_publish(publish_opts()) -> iodata(). +encode_publish(Opts) -> + Topic = maps:get(topic, Opts), + Message = maps:get(message, Opts), + QoS = maps:get(qos, Opts, 0), + Retain = maps:get(retain, Opts, false), + Dup = maps:get(dup, Opts, false), + + DupBit = bool_to_bit(Dup), + RetainBit = bool_to_bit(Retain), + + TopicBin = encode_utf8(Topic), + VarHeader = + case QoS of + 0 -> + TopicBin; + _ -> + PacketId = maps:get(packet_id, Opts), + [TopicBin, <>] + end, + + Body = [VarHeader, Message], + RemainingLen = encode_remaining_length(iolist_size(Body)), + [<>, RemainingLen, Body]. + +%% @doc Encode a PUBACK acknowledgment for `PacketId' (QoS 1). +-spec encode_puback(packet_id()) -> binary(). +encode_puback(PacketId) -> + <>. + +%% @doc Encode a PUBREC acknowledgment for `PacketId' (QoS 2, step 1). +-spec encode_pubrec(packet_id()) -> binary(). +encode_pubrec(PacketId) -> + <>. + +%% @doc Encode a PUBREL packet for `PacketId' (QoS 2, step 2). +-spec encode_pubrel(packet_id()) -> binary(). +encode_pubrel(PacketId) -> + <>. + +%% @doc Encode a PUBCOMP acknowledgment for `PacketId' (QoS 2, step 3). +-spec encode_pubcomp(packet_id()) -> binary(). +encode_pubcomp(PacketId) -> + <>. + +%% @doc Encode a SUBSCRIBE packet. +%% +%% `Opts' must include `packet_id' and a non-empty `topics' list of +%% `{TopicFilter, RequestedQoS}' pairs. +%% @end +-spec encode_subscribe(subscribe_opts()) -> iodata(). +encode_subscribe(Opts) -> + PacketId = maps:get(packet_id, Opts), + Topics = maps:get(topics, Opts), + + Body = [<>, [[encode_utf8(Topic), QoS] || {Topic, QoS} <- Topics]], + RemainingLen = encode_remaining_length(iolist_size(Body)), + [<>, RemainingLen, Body]. + +%% @doc Encode an UNSUBSCRIBE packet. +%% +%% `Opts' must include `packet_id' and a non-empty `topics' list of +%% topic filters. +%% @end +-spec encode_unsubscribe(unsubscribe_opts()) -> iodata(). +encode_unsubscribe(Opts) -> + PacketId = maps:get(packet_id, Opts), + Topics = maps:get(topics, Opts), + + Body = [<>, [encode_utf8(Topic) || Topic <- Topics]], + RemainingLen = encode_remaining_length(iolist_size(Body)), + [<>, RemainingLen, Body]. + +%% @doc Encode a PINGREQ packet. +-spec encode_pingreq() -> binary(). +encode_pingreq() -> + <>. + +%% @doc Encode a DISCONNECT packet. +-spec encode_disconnect() -> binary(). +encode_disconnect() -> + <>. + +%% ------------------------------------------------------------------- +%% Unified Decoder +%% ------------------------------------------------------------------- + +%% @doc Decode the next MQTT control packet from `Bin'. +%% +%% Returns `{ok, {Type, Data}, Rest}' on success, where `Type' is the +%% packet kind and `Data' is the decoded fields map. `Rest' contains +%% any bytes after the consumed packet and may be passed back to +%% `decode/1' to extract the following packet. +%% +%% Returns `{error, incomplete}' if the buffer does not yet contain a +%% full packet, or `{error, {protocol_error, Reason}}' if the bytes +%% violate MQTT 3.1.1 (reserved flag bits, reserved QoS, packet ID +%% zero, malformed UTF-8 length, etc.). +%% @end +-spec decode(binary()) -> + {ok, decoded_packet(), Rest :: binary()} + | {error, incomplete} + | {error, {protocol_error, term()}}. +decode(<<>>) -> + {error, incomplete}; +decode(<>) -> + case decode_remaining_length(Rest) of + {ok, Length, Payload} when byte_size(Payload) >= Length -> + <> = Payload, + case decode_packet(Type, Flags, PacketData) of + {ok, Decoded} -> + {ok, Decoded, Remaining}; + {error, Reason} -> + {error, {protocol_error, Reason}} + end; + {ok, _Length, _Payload} -> + {error, incomplete}; + {error, incomplete} -> + {error, incomplete}; + {error, Reason} -> + {error, {protocol_error, Reason}} + end. + +%% ------------------------------------------------------------------- +%% Packet Decoders +%% ------------------------------------------------------------------- + +%% Per MQTT 3.1.1 §2.2.2, fixed-header flags are reserved for most types +%% (must be 0) and 0x2 for PUBREL / SUBSCRIBE / UNSUBSCRIBE. PUBLISH carries +%% DUP/QoS/RETAIN. Anything else is a malformed packet. +decode_packet(?CONNACK, 0, <<0:7, SP:1, ReturnCode>>) when ReturnCode =< 5 -> + SessionPresent = SP =:= 1, + if + SessionPresent andalso ReturnCode =/= 0 -> + {error, {bad_connack, session_present_with_error}}; + true -> + {ok, {connack, #{session_present => SessionPresent, return_code => ReturnCode}}} + end; +decode_packet(?PUBLISH, Flags, Data) -> + Dup = (Flags band 8) =:= 8, + QoS = (Flags bsr 1) band 3, + Retain = (Flags band 1) =:= 1, + case QoS of + 3 -> + {error, {bad_publish, qos_reserved}}; + _ -> + decode_publish(Dup, QoS, Retain, Data) + end; +decode_packet(?PUBACK, 0, <>) when PacketId =/= 0 -> + {ok, {puback, #{packet_id => PacketId}}}; +decode_packet(?PUBREC, 0, <>) when PacketId =/= 0 -> + {ok, {pubrec, #{packet_id => PacketId}}}; +decode_packet(?PUBREL, 2, <>) when PacketId =/= 0 -> + {ok, {pubrel, #{packet_id => PacketId}}}; +decode_packet(?PUBCOMP, 0, <>) when PacketId =/= 0 -> + {ok, {pubcomp, #{packet_id => PacketId}}}; +decode_packet(?SUBACK, 0, <>) when + PacketId =/= 0, byte_size(ReturnCodes) >= 1 +-> + RCs = binary_to_list(ReturnCodes), + case lists:all(fun valid_suback_rc/1, RCs) of + true -> {ok, {suback, #{packet_id => PacketId, return_codes => RCs}}}; + false -> {error, {bad_suback, return_codes}} + end; +decode_packet(?UNSUBACK, 0, <>) when PacketId =/= 0 -> + {ok, {unsuback, #{packet_id => PacketId}}}; +decode_packet(?PINGRESP, 0, <<>>) -> + {ok, {pingresp, #{}}}; +decode_packet(?PINGREQ, 0, <<>>) -> + {ok, {pingreq, #{}}}; +decode_packet(?CONNECT, 0, Data) -> + case decode_connect_var_header(Data) of + {ok, Result} -> {ok, {connect, Result}}; + {error, _} = Err -> Err + end; +decode_packet(?SUBSCRIBE, 2, <>) when PacketId =/= 0 -> + case decode_subscribe_topics(Payload, []) of + {ok, Topics} -> + {ok, {subscribe, #{packet_id => PacketId, topics => Topics}}}; + {error, _} = Err -> + Err + end; +decode_packet(?UNSUBSCRIBE, 2, <>) when PacketId =/= 0 -> + case decode_unsubscribe_topics(Payload, []) of + {ok, Topics} -> + {ok, {unsubscribe, #{packet_id => PacketId, topics => Topics}}}; + {error, _} = Err -> + Err + end; +decode_packet(?DISCONNECT, 0, <<>>) -> + {ok, {disconnect, #{}}}; +decode_packet(Type, _Flags, _Data) when + Type < ?CONNECT; Type > ?DISCONNECT +-> + {error, {unknown_packet_type, Type}}; +decode_packet(_Type, _Flags, _Data) -> + {error, malformed_packet}. + +decode_publish(Dup, 0, Retain, Data) -> + case decode_utf8(Data) of + {ok, Topic, Rest} -> + {ok, + {publish, #{ + topic => Topic, + message => Rest, + qos => 0, + dup => Dup, + retain => Retain + }}}; + {error, _} = Err -> + Err + end; +decode_publish(Dup, QoS, Retain, Data) -> + case decode_utf8(Data) of + {ok, Topic, Rest} when byte_size(Rest) >= 2 -> + <> = Rest, + case PacketId of + 0 -> + {error, {bad_packet_id, 0}}; + _ -> + {ok, + {publish, #{ + topic => Topic, + message => Message, + qos => QoS, + dup => Dup, + retain => Retain, + packet_id => PacketId + }}} + end; + {ok, _Topic, _Rest} -> + {error, {bad_publish, too_short}}; + {error, _} = Err -> + Err + end. + +valid_suback_rc(0) -> true; +valid_suback_rc(1) -> true; +valid_suback_rc(2) -> true; +valid_suback_rc(16#80) -> true; +valid_suback_rc(_) -> false. + +%% ------------------------------------------------------------------- +%% CONNECT Decoder (for server-side reuse) +%% ------------------------------------------------------------------- + +decode_connect_var_header(<< + 0, + 4, + "MQTT", + 4, + HasUsernameBit:1, + HasPasswordBit:1, + WillRetainBit:1, + WillQoS:2, + HasWillBit:1, + CleanSessionBit:1, + _Reserved:1, + KeepAliveSeconds:16/big, + Payload/binary +>>) -> + CleanSession = CleanSessionBit =:= 1, + HasWill = HasWillBit =:= 1, + WillRetain = WillRetainBit =:= 1, + HasUsername = HasUsernameBit =:= 1, + HasPassword = HasPasswordBit =:= 1, + + case decode_utf8(Payload) of + {ok, ClientId, Rest0} -> + case decode_will(HasWill, WillQoS, WillRetain, Rest0) of + {ok, WillProps, Rest1} -> + case decode_credentials(HasUsername, HasPassword, Rest1) of + {ok, CredProps, _Rest2} -> + Base = #{ + client_id => ClientId, + keep_alive_seconds => KeepAliveSeconds, + clean_session => CleanSession + }, + {ok, maps:merge(maps:merge(Base, WillProps), CredProps)}; + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end; +decode_connect_var_header(_) -> + {error, bad_connect}. + +decode_will(false, _QoS, _Retain, Rest) -> + {ok, #{}, Rest}; +decode_will(true, QoS, Retain, Rest) -> + case decode_utf8(Rest) of + {ok, WillTopic, Rest1} -> + case decode_utf8(Rest1) of + {ok, WillMessage, Rest2} -> + {ok, + #{ + will_topic => WillTopic, + will_message => WillMessage, + will_qos => QoS, + will_retain => Retain + }, + Rest2}; + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end. + +decode_credentials(false, false, Rest) -> + {ok, #{}, Rest}; +decode_credentials(true, false, Rest) -> + case decode_utf8(Rest) of + {ok, Username, Rest1} -> + {ok, #{username => Username}, Rest1}; + {error, _} = Err -> + Err + end; +decode_credentials(true, true, Rest) -> + case decode_utf8(Rest) of + {ok, Username, Rest1} -> + case decode_utf8(Rest1) of + {ok, Password, Rest2} -> + {ok, #{username => Username, password => Password}, Rest2}; + {error, _} = Err -> + Err + end; + {error, _} = Err -> + Err + end; +decode_credentials(false, true, _Rest) -> + {error, password_without_username}. + +decode_subscribe_topics(<<>>, Acc) -> + {ok, lists:reverse(Acc)}; +decode_subscribe_topics(Bin, Acc) -> + case decode_utf8(Bin) of + {ok, Topic, <>} -> + decode_subscribe_topics(Rest, [{Topic, QoS} | Acc]); + _ -> + {error, bad_subscribe_payload} + end. + +decode_unsubscribe_topics(<<>>, Acc) -> + {ok, lists:reverse(Acc)}; +decode_unsubscribe_topics(Bin, Acc) -> + case decode_utf8(Bin) of + {ok, Topic, Rest} -> + decode_unsubscribe_topics(Rest, [Topic | Acc]); + _ -> + {error, bad_unsubscribe_payload} + end. + +%% ------------------------------------------------------------------- +%% Helpers +%% ------------------------------------------------------------------- + +bool_to_bit(true) -> 1; +bool_to_bit(false) -> 0. diff --git a/amqtt_client/test/amqtt_client_tests.erl b/amqtt_client/test/amqtt_client_tests.erl new file mode 100644 index 0000000..0a031dc --- /dev/null +++ b/amqtt_client/test/amqtt_client_tests.erl @@ -0,0 +1,1133 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(amqtt_client_tests). + +-include_lib("eunit/include/eunit.hrl"). + +-define(RECV_TIMEOUT, 1000). +-define(TEST_TIMEOUT, 10). + +-define(ASSERT_RECEIVE(Pattern, Timeout), + receive + Pattern -> ok + after (Timeout) -> + erlang:error({receive_timeout, ??Pattern}) + end +). + +%% ------------------------------------------------------------------- +%% Mock broker helpers +%% ------------------------------------------------------------------- + +start_mock_broker() -> + {ok, Listener} = gen_tcp:listen(0, [binary, {active, false}, {reuseaddr, true}]), + {ok, Port} = inet:port(Listener), + {Listener, Port}. + +accept(Listener) -> + {ok, Socket} = gen_tcp:accept(Listener, 5000), + Socket. + +recv_packet(Socket) -> + {ok, Data} = gen_tcp:recv(Socket, 0, 5000), + Data. + +send_broker(Socket, Data) -> + ok = gen_tcp:send(Socket, Data). + +send_connack(Socket) -> send_connack(Socket, 0). +send_connack(Socket, ReturnCode) -> + send_broker(Socket, <<16#20, 2, 0, ReturnCode>>). + +%% Spawn an async caller that runs Fun and replies with the result to Self. +async_call(Fun) -> + Self = self(), + Ref = make_ref(), + Pid = spawn(fun() -> Self ! {Ref, Fun()} end), + {Ref, Pid}. + +await({Ref, _Pid}, Timeout) -> + receive + {Ref, Result} -> Result + after Timeout -> + erlang:error(async_call_timeout) + end. + +connect_and_handshake(Port, ClientId) -> + connect_and_handshake(Port, ClientId, 0). + +connect_and_handshake(Port, ClientId, KeepAliveSeconds) -> + {ok, Client} = amqtt_client:connect(#{ + host => "127.0.0.1", + port => Port, + client_id => ClientId, + keep_alive_seconds => KeepAliveSeconds + }), + Client. + +cleanup(Listener, Client) -> + try + amqtt_client:disconnect(Client) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok. + +%% ------------------------------------------------------------------- +%% Connection Tests +%% ------------------------------------------------------------------- + +connect_and_receive_connack_success_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"test_client">>), + BrokerSocket = accept(Listener), + ConnectData = recv_packet(BrokerSocket), + {ok, {connect, ConnectInfo}, <<>>} = amqtt_proto:decode(ConnectData), + ?assertEqual(<<"test_client">>, maps:get(client_id, ConnectInfo)), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 0}}, ?RECV_TIMEOUT), + cleanup(Listener, Client) + end}. + +connect_and_receive_connack_failure_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket, 5), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 5}}, ?RECV_TIMEOUT), + try + gen_tcp:close(BrokerSocket) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +%% ------------------------------------------------------------------- +%% Publish QoS 0 +%% ------------------------------------------------------------------- + +publish_qos0_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 0}}, ?RECV_TIMEOUT), + + ok = amqtt_client:publish(Client, <<"topic/a">>, <<"hello">>, 0), + + PubData = recv_packet(BrokerSocket), + {ok, {publish, PubInfo}, <<>>} = amqtt_proto:decode(PubData), + ?assertEqual(<<"topic/a">>, maps:get(topic, PubInfo)), + ?assertEqual(<<"hello">>, maps:get(message, PubInfo)), + ?assertEqual(0, maps:get(qos, PubInfo)), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Subscribe +%% ------------------------------------------------------------------- + +subscribe_and_receive_suback_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + SubAsync = async_call(fun() -> + amqtt_client:subscribe(Client, [{<<"test/topic">>, 1}]) + end), + + SubData = recv_packet(BrokerSocket), + {ok, {subscribe, SubInfo}, <<>>} = amqtt_proto:decode(SubData), + ?assertEqual([{<<"test/topic">>, 1}], maps:get(topics, SubInfo)), + PacketId = maps:get(packet_id, SubInfo), + + send_broker(BrokerSocket, <<16#90, 3, PacketId:16/big, 1>>), + + ?assertEqual({ok, [1]}, await(SubAsync, 2000)), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Receive Message +%% ------------------------------------------------------------------- + +receive_published_message_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PublishPacket = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"sensor/temp">>, message => <<"22.5">>, qos => 0 + }) + ), + send_broker(BrokerSocket, PublishPacket), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{ + topic := <<"sensor/temp">>, message := <<"22.5">>, qos := 0 + }}, + ?RECV_TIMEOUT + ), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Disconnect +%% ------------------------------------------------------------------- + +disconnect_sends_packet_and_stops_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + Ref = monitor(process, Client), + ok = amqtt_client:disconnect(Client), + + DisconnectData = recv_packet(BrokerSocket), + ?assertMatch({ok, {disconnect, #{}}, <<>>}, amqtt_proto:decode(DisconnectData)), + + ?ASSERT_RECEIVE({'DOWN', Ref, process, Client, normal}, ?RECV_TIMEOUT), + + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +%% ------------------------------------------------------------------- +%% TCP Closed +%% ------------------------------------------------------------------- + +tcp_closed_notifies_owner_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + gen_tcp:close(BrokerSocket), + + ?ASSERT_RECEIVE({mqtt, Client, disconnected, #{}}, ?RECV_TIMEOUT), + + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +%% ------------------------------------------------------------------- +%% Ping +%% ------------------------------------------------------------------- + +automatic_ping_on_keep_alive_seconds_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + %% 1 second keep-alive -> ping at 750ms + Client = connect_and_handshake(Port, <<"c">>, 1), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PingData = recv_packet(BrokerSocket), + ?assertMatch({ok, {pingreq, #{}}, <<>>}, amqtt_proto:decode(PingData)), + + send_broker(BrokerSocket, <<16#D0, 0>>), + + cleanup(Listener, Client) + end}. + +manual_ping_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + amqtt_client:ping(Client), + + PingData = recv_packet(BrokerSocket), + ?assertMatch({ok, {pingreq, #{}}, <<>>}, amqtt_proto:decode(PingData)), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% QoS 1 +%% ------------------------------------------------------------------- + +publish_qos1_waits_for_puback_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PubAsync = async_call(fun() -> + amqtt_client:publish(Client, <<"t">>, <<"m">>, 1) + end), + + PubData = recv_packet(BrokerSocket), + {ok, {publish, PubInfo}, <<>>} = amqtt_proto:decode(PubData), + ?assertEqual(1, maps:get(qos, PubInfo)), + PacketId = maps:get(packet_id, PubInfo), + + send_broker(BrokerSocket, amqtt_proto:encode_puback(PacketId)), + + ?assertEqual({ok, PacketId}, await(PubAsync, 2000)), + + cleanup(Listener, Client) + end}. + +receive_publish_qos1_auto_puback_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PublishPacket = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"msg">>, qos => 1, packet_id => 7 + }) + ), + send_broker(BrokerSocket, PublishPacket), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{topic := <<"t">>, qos := 1, packet_id := 7}}, + ?RECV_TIMEOUT + ), + + AckData = recv_packet(BrokerSocket), + ?assertMatch({ok, {puback, #{packet_id := 7}}, <<>>}, amqtt_proto:decode(AckData)), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% QoS 2 +%% ------------------------------------------------------------------- + +publish_qos2_full_flow_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PubAsync = async_call(fun() -> + amqtt_client:publish(Client, <<"t">>, <<"m">>, 2) + end), + + PubData = recv_packet(BrokerSocket), + {ok, {publish, PubInfo}, <<>>} = amqtt_proto:decode(PubData), + ?assertEqual(2, maps:get(qos, PubInfo)), + PacketId = maps:get(packet_id, PubInfo), + + send_broker(BrokerSocket, amqtt_proto:encode_pubrec(PacketId)), + + PubrelData = recv_packet(BrokerSocket), + ?assertMatch( + {ok, {pubrel, #{packet_id := PacketId}}, <<>>}, + amqtt_proto:decode(PubrelData) + ), + + send_broker(BrokerSocket, amqtt_proto:encode_pubcomp(PacketId)), + + ?assertEqual({ok, PacketId}, await(PubAsync, 2000)), + + cleanup(Listener, Client) + end}. + +receive_publish_qos2_full_flow_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PublishPacket = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"qos2msg">>, qos => 2, packet_id => 10 + }) + ), + send_broker(BrokerSocket, PublishPacket), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{topic := <<"t">>, qos := 2, packet_id := 10}}, + ?RECV_TIMEOUT + ), + + PubrecData = recv_packet(BrokerSocket), + ?assertMatch( + {ok, {pubrec, #{packet_id := 10}}, <<>>}, amqtt_proto:decode(PubrecData) + ), + + send_broker(BrokerSocket, amqtt_proto:encode_pubrel(10)), + + PubcompData = recv_packet(BrokerSocket), + ?assertMatch( + {ok, {pubcomp, #{packet_id := 10}}, <<>>}, amqtt_proto:decode(PubcompData) + ), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Unsubscribe +%% ------------------------------------------------------------------- + +unsubscribe_and_receive_unsuback_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + UnsubAsync = async_call(fun() -> + amqtt_client:unsubscribe(Client, [<<"test/topic">>]) + end), + + UnsubData = recv_packet(BrokerSocket), + {ok, {unsubscribe, UnsubInfo}, <<>>} = amqtt_proto:decode(UnsubData), + ?assertEqual([<<"test/topic">>], maps:get(topics, UnsubInfo)), + PacketId = maps:get(packet_id, UnsubInfo), + + send_broker(BrokerSocket, <<16#B0, 2, PacketId:16/big>>), + + ?assertEqual(ok, await(UnsubAsync, 2000)), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Custom Transport +%% ------------------------------------------------------------------- + +connect_with_pre_opened_gen_tcp_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, true}]), + + {ok, Client} = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + client_id => <<"custom_transport">>, + keep_alive_seconds => 0 + }), + + BrokerSocket = accept(Listener), + ConnectData = recv_packet(BrokerSocket), + {ok, {connect, ConnectInfo}, <<>>} = amqtt_proto:decode(ConnectData), + ?assertEqual(<<"custom_transport">>, maps:get(client_id, ConnectInfo)), + + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 0}}, ?RECV_TIMEOUT), + + ok = amqtt_client:publish(Client, <<"t">>, <<"via-custom">>, 0), + PubData = recv_packet(BrokerSocket), + {ok, {publish, PubInfo}, <<>>} = amqtt_proto:decode(PubData), + ?assertEqual(<<"via-custom">>, maps:get(message, PubInfo)), + + send_broker( + BrokerSocket, + iolist_to_binary( + amqtt_proto:encode_publish(#{topic => <<"t">>, message => <<"back">>, qos => 0}) + ) + ), + ?ASSERT_RECEIVE({mqtt, Client, publish, #{message := <<"back">>}}, ?RECV_TIMEOUT), + + cleanup(Listener, Client) + end}. + +connect_with_custom_tags_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, true}]), + + {ok, Client} = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + transport_tags => {tcp, tcp_closed, tcp_error}, + client_id => <<"c">>, + keep_alive_seconds => 0 + }), + + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 0}}, ?RECV_TIMEOUT), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% Passive-Mode Transport (reader process) +%% ------------------------------------------------------------------- + +passive_transport_publish_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, false}]), + + {ok, Client} = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + active => false, + transport_tags => {tcp, tcp_closed, tcp_error}, + client_id => <<"passive_test">>, + keep_alive_seconds => 0 + }), + + BrokerSocket = accept(Listener), + ConnectData = recv_packet(BrokerSocket), + {ok, {connect, ConnectInfo}, <<>>} = amqtt_proto:decode(ConnectData), + ?assertEqual(<<"passive_test">>, maps:get(client_id, ConnectInfo)), + + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 0}}, ?RECV_TIMEOUT), + + ok = amqtt_client:publish(Client, <<"t">>, <<"passive_msg">>, 0), + PubData = recv_packet(BrokerSocket), + {ok, {publish, PubInfo}, <<>>} = amqtt_proto:decode(PubData), + ?assertEqual(<<"passive_msg">>, maps:get(message, PubInfo)), + + send_broker( + BrokerSocket, + iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"from_broker">>, qos => 0 + }) + ) + ), + ?ASSERT_RECEIVE({mqtt, Client, publish, #{message := <<"from_broker">>}}, ?RECV_TIMEOUT), + + cleanup(Listener, Client) + end}. + +passive_transport_subscribe_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, false}]), + + {ok, Client} = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + active => false, + transport_tags => {tcp, tcp_closed, tcp_error}, + client_id => <<"c">>, + keep_alive_seconds => 0 + }), + + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + SubAsync = async_call(fun() -> + amqtt_client:subscribe(Client, [{<<"topic">>, 0}]) + end), + + SubData = recv_packet(BrokerSocket), + {ok, {subscribe, SubInfo}, <<>>} = amqtt_proto:decode(SubData), + PacketId = maps:get(packet_id, SubInfo), + + send_broker(BrokerSocket, <<16#90, 3, PacketId:16/big, 0>>), + ?assertEqual({ok, [0]}, await(SubAsync, 2000)), + + cleanup(Listener, Client) + end}. + +passive_transport_closed_notifies_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, false}]), + + {ok, Client} = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + active => false, + transport_tags => {tcp, tcp_closed, tcp_error}, + client_id => <<"c">>, + keep_alive_seconds => 0 + }), + + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + gen_tcp:close(BrokerSocket), + + ?ASSERT_RECEIVE({mqtt, Client, disconnected, #{}}, ?RECV_TIMEOUT), + + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +%% ------------------------------------------------------------------- +%% Buffer fragmentation +%% ------------------------------------------------------------------- + +handles_fragmented_tcp_delivery_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + PublishPacket = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"frag/test">>, message => <<"data">>, qos => 0 + }) + ), + Size = byte_size(PublishPacket), + Half = Size div 2, + <> = PublishPacket, + + send_broker(BrokerSocket, Part1), + timer:sleep(50), + send_broker(BrokerSocket, Part2), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{topic := <<"frag/test">>, message := <<"data">>}}, + ?RECV_TIMEOUT + ), + + cleanup(Listener, Client) + end}. + +%% ------------------------------------------------------------------- +%% CONNACK gating, dedup, owner monitor, buffer cap, protocol error +%% ------------------------------------------------------------------- + +publish_before_connack_returns_not_connected_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + + %% No CONNACK has been delivered yet, connected = false. + ?assertEqual( + {error, not_connected}, + amqtt_client:publish(Client, <<"t">>, <<"m">>, 1) + ), + ?assertEqual( + {error, not_connected}, + amqtt_client:subscribe(Client, [{<<"t">>, 0}]) + ), + + cleanup(Listener, Client) + end}. + +connack_non_zero_stops_normally_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + process_flag(trap_exit, true), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket, 4), + ?ASSERT_RECEIVE({mqtt, Client, connack, #{return_code := 4}}, ?RECV_TIMEOUT), + ?ASSERT_RECEIVE({'EXIT', Client, normal}, ?RECV_TIMEOUT), + process_flag(trap_exit, false), + try + gen_tcp:close(BrokerSocket) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +inbound_qos2_dedup_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + Publish = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 2, packet_id => 99 + }) + ), + + send_broker(BrokerSocket, Publish), + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{packet_id := 99, qos := 2}}, + ?RECV_TIMEOUT + ), + ?assertMatch( + {ok, {pubrec, #{packet_id := 99}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + + %% Duplicate PUBLISH: PUBREC re-sent (broker is retransmitting), but + %% the owner must NOT receive the same message a second time. + send_broker(BrokerSocket, Publish), + ?assertMatch( + {ok, {pubrec, #{packet_id := 99}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + receive + {mqtt, Client, publish, _} -> + erlang:error(duplicate_publish_delivered) + after 200 -> + ok + end, + + %% PUBREL releases the packet ID; a subsequent PUBLISH with the same + %% ID is treated as new and IS delivered. + send_broker(BrokerSocket, amqtt_proto:encode_pubrel(99)), + ?assertMatch( + {ok, {pubcomp, #{packet_id := 99}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + send_broker(BrokerSocket, Publish), + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{packet_id := 99, qos := 2}}, + ?RECV_TIMEOUT + ), + + cleanup(Listener, Client) + end}. + +owner_death_stops_client_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Self = self(), + Owner = spawn(fun() -> + receive + {connect, ConnInfo} -> + Self ! {owner_connected, ConnInfo}, + receive + die -> ok + end + end + end), + + {ok, Client} = amqtt_client:connect(#{ + host => "127.0.0.1", + port => Port, + client_id => <<"owner_death">>, + keep_alive_seconds => 0, + owner => Owner + }), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + + ClientRef = monitor(process, Client), + + exit(Owner, kill), + + ?ASSERT_RECEIVE({'DOWN', ClientRef, process, Client, normal}, ?RECV_TIMEOUT), + + try + gen_tcp:close(BrokerSocket) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +buffer_overflow_stops_with_error_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + process_flag(trap_exit, true), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + %% Send 300 KiB of garbage that does not parse as a complete packet: + %% start with a fixed header claiming a huge remaining-length, then + %% never deliver the body. The buffer should overflow at 256 KiB. + Header = <<16#30, 16#FF, 16#FF, 16#FF, 16#7F>>, + Garbage = binary:copy(<<"X">>, 300 * 1024), + send_broker(BrokerSocket, <
>), + + ?ASSERT_RECEIVE({mqtt, Client, error, #{reason := buffer_overflow}}, 3000), + ?ASSERT_RECEIVE({'EXIT', Client, {transport_error, buffer_overflow}}, 3000), + process_flag(trap_exit, false), + + try + gen_tcp:close(BrokerSocket) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +protocol_error_stops_connection_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + process_flag(trap_exit, true), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + %% Send a PUBACK with packet_id 0: the hardened decoder rejects it. + send_broker(BrokerSocket, <<16#40, 2, 0, 0>>), + + ?ASSERT_RECEIVE({mqtt, Client, error, #{reason := {protocol_error, _}}}, ?RECV_TIMEOUT), + ?ASSERT_RECEIVE({'EXIT', Client, {transport_error, {protocol_error, _}}}, ?RECV_TIMEOUT), + process_flag(trap_exit, false), + + try + gen_tcp:close(BrokerSocket) + catch + _:_ -> ok + end, + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +connect_send_failure_does_not_kill_caller_test_() -> + %% Regression: handle_call(send_connect_packet) on send failure must stop + %% the gen_server with reason 'normal' rather than abnormal, otherwise the + %% link from start_link propagates an exit signal that kills a non-trap_exit + %% caller before it can observe the documented {error, _} return. + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + {ok, Socket} = gen_tcp:connect("127.0.0.1", Port, [binary, {active, false}]), + ok = gen_tcp:close(Socket), + + Self = self(), + Caller = spawn(fun() -> + Result = amqtt_client:connect(#{ + transport => {gen_tcp, Socket}, + client_id => <<"send_fail">>, + keep_alive_seconds => 0, + owner => Self + }), + Self ! {result, self(), Result}, + timer:sleep(150) + end), + Ref = monitor(process, Caller), + + ?ASSERT_RECEIVE({result, Caller, {error, _}}, ?RECV_TIMEOUT), + ?ASSERT_RECEIVE({'DOWN', Ref, process, Caller, normal}, ?RECV_TIMEOUT), + + try + gen_tcp:close(Listener) + catch + _:_ -> ok + end, + ok + end}. + +%% ------------------------------------------------------------------- +%% Manual ACK +%% ------------------------------------------------------------------- + +connect_manual_ack(Port, ClientId) -> + {ok, Client} = amqtt_client:connect(#{ + host => "127.0.0.1", + port => Port, + client_id => ClientId, + keep_alive_seconds => 0, + auto_ack => false + }), + Client. + +assert_no_broker_data(Socket, Timeout) -> + case gen_tcp:recv(Socket, 0, Timeout) of + {error, timeout} -> ok; + {ok, Data} -> erlang:error({unexpected_broker_data, Data}) + end. + +manual_ack_qos1_holds_puback_until_ack_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + send_broker( + BrokerSocket, + iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 1, packet_id => 11 + }) + ) + ), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{packet_id := 11, qos := 1}}, + ?RECV_TIMEOUT + ), + ok = assert_no_broker_data(BrokerSocket, 200), + + ok = amqtt_client:ack(Client, 11), + AckData = recv_packet(BrokerSocket), + ?assertMatch({ok, {puback, #{packet_id := 11}}, <<>>}, amqtt_proto:decode(AckData)), + + cleanup(Listener, Client) + end}. + +manual_ack_qos2_holds_pubrec_until_ack_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + send_broker( + BrokerSocket, + iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 2, packet_id => 22 + }) + ) + ), + + ?ASSERT_RECEIVE( + {mqtt, Client, publish, #{packet_id := 22, qos := 2}}, + ?RECV_TIMEOUT + ), + ok = assert_no_broker_data(BrokerSocket, 200), + + ok = amqtt_client:ack(Client, 22), + ?assertMatch( + {ok, {pubrec, #{packet_id := 22}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + + send_broker(BrokerSocket, amqtt_proto:encode_pubrel(22)), + ?assertMatch( + {ok, {pubcomp, #{packet_id := 22}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + + cleanup(Listener, Client) + end}. + +manual_ack_qos2_dup_publish_before_ack_no_pubrec_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + Pub = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 2, packet_id => 33 + }) + ), + send_broker(BrokerSocket, Pub), + ?ASSERT_RECEIVE({mqtt, Client, publish, #{packet_id := 33}}, ?RECV_TIMEOUT), + + %% Duplicate retransmit before owner acks: no event, no PUBREC. + send_broker(BrokerSocket, Pub), + receive + {mqtt, Client, publish, _} -> erlang:error(duplicate_event) + after 200 -> ok + end, + ok = assert_no_broker_data(BrokerSocket, 200), + + ok = amqtt_client:ack(Client, 33), + ?assertMatch( + {ok, {pubrec, #{packet_id := 33}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + ok = assert_no_broker_data(BrokerSocket, 200), + + cleanup(Listener, Client) + end}. + +manual_ack_qos2_dup_publish_after_ack_resends_pubrec_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + Pub = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 2, packet_id => 44 + }) + ), + send_broker(BrokerSocket, Pub), + ?ASSERT_RECEIVE({mqtt, Client, publish, #{packet_id := 44}}, ?RECV_TIMEOUT), + + ok = amqtt_client:ack(Client, 44), + ?assertMatch( + {ok, {pubrec, #{packet_id := 44}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + + %% Broker hasn't seen our PUBREC; retransmits PUBLISH. We must re-send + %% PUBREC and not redeliver to the owner. + send_broker(BrokerSocket, Pub), + ?assertMatch( + {ok, {pubrec, #{packet_id := 44}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + receive + {mqtt, Client, publish, _} -> erlang:error(duplicate_event) + after 200 -> ok + end, + + cleanup(Listener, Client) + end}. + +manual_ack_idempotent_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + send_broker( + BrokerSocket, + iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 2, packet_id => 55 + }) + ) + ), + ?ASSERT_RECEIVE({mqtt, Client, publish, #{packet_id := 55}}, ?RECV_TIMEOUT), + + ok = amqtt_client:ack(Client, 55), + ?assertMatch( + {ok, {pubrec, #{packet_id := 55}}, <<>>}, + amqtt_proto:decode(recv_packet(BrokerSocket)) + ), + + %% Second ack on the same id is a no-op: ok return, no extra PUBREC. + ok = amqtt_client:ack(Client, 55), + ok = assert_no_broker_data(BrokerSocket, 200), + + cleanup(Listener, Client) + end}. + +manual_ack_unknown_packet_id_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_manual_ack(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + ?assertEqual({error, not_pending}, amqtt_client:ack(Client, 9999)), + + cleanup(Listener, Client) + end}. + +manual_ack_on_auto_ack_client_errors_test_() -> + {timeout, ?TEST_TIMEOUT, fun() -> + {Listener, Port} = start_mock_broker(), + Client = connect_and_handshake(Port, <<"c">>), + BrokerSocket = accept(Listener), + _ConnectData = recv_packet(BrokerSocket), + send_connack(BrokerSocket), + ?ASSERT_RECEIVE({mqtt, Client, connack, _}, ?RECV_TIMEOUT), + + ?assertEqual({error, auto_ack_enabled}, amqtt_client:ack(Client, 1)), + + cleanup(Listener, Client) + end}. diff --git a/amqtt_client/test/amqtt_proto_tests.erl b/amqtt_client/test/amqtt_proto_tests.erl new file mode 100644 index 0000000..40d9680 --- /dev/null +++ b/amqtt_client/test/amqtt_proto_tests.erl @@ -0,0 +1,455 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(amqtt_proto_tests). + +-include_lib("eunit/include/eunit.hrl"). + +%% ------------------------------------------------------------------- +%% Remaining Length Encoding/Decoding +%% ------------------------------------------------------------------- + +encode_remaining_length_0_test() -> + ?assertEqual(<<0>>, amqtt_proto:encode_remaining_length(0)). + +encode_remaining_length_127_test() -> + ?assertEqual(<<127>>, amqtt_proto:encode_remaining_length(127)). + +encode_remaining_length_128_test() -> + ?assertEqual(<<128, 1>>, amqtt_proto:encode_remaining_length(128)). + +encode_remaining_length_16383_test() -> + ?assertEqual(<<16#FF, 16#7F>>, amqtt_proto:encode_remaining_length(16383)). + +encode_remaining_length_16384_test() -> + ?assertEqual(<<16#80, 16#80, 1>>, amqtt_proto:encode_remaining_length(16384)). + +encode_remaining_length_2097151_test() -> + ?assertEqual(<<16#FF, 16#FF, 16#7F>>, amqtt_proto:encode_remaining_length(2097151)). + +encode_remaining_length_2097152_test() -> + ?assertEqual(<<16#80, 16#80, 16#80, 1>>, amqtt_proto:encode_remaining_length(2097152)). + +encode_remaining_length_max_test() -> + ?assertEqual(<<16#FF, 16#FF, 16#FF, 16#7F>>, amqtt_proto:encode_remaining_length(268435455)). + +decode_remaining_length_single_byte_test() -> + ?assertEqual({ok, 0, <<>>}, amqtt_proto:decode_remaining_length(<<0>>)), + ?assertEqual({ok, 127, <<>>}, amqtt_proto:decode_remaining_length(<<127>>)). + +decode_remaining_length_two_bytes_test() -> + ?assertEqual({ok, 128, <<>>}, amqtt_proto:decode_remaining_length(<<128, 1>>)), + ?assertEqual({ok, 16383, <<>>}, amqtt_proto:decode_remaining_length(<<16#FF, 16#7F>>)). + +decode_remaining_length_with_trailing_data_test() -> + ?assertEqual({ok, 128, <<99>>}, amqtt_proto:decode_remaining_length(<<128, 1, 99>>)). + +decode_remaining_length_incomplete_test() -> + ?assertEqual({error, incomplete}, amqtt_proto:decode_remaining_length(<<>>)), + ?assertEqual({error, incomplete}, amqtt_proto:decode_remaining_length(<<128>>)), + ?assertEqual({error, incomplete}, amqtt_proto:decode_remaining_length(<<128, 128>>)). + +remaining_length_roundtrip_test() -> + Ns = [0, 1, 127, 128, 255, 16383, 16384, 2097151, 2097152, 268435455], + lists:foreach( + fun(N) -> + Encoded = amqtt_proto:encode_remaining_length(N), + ?assertEqual({ok, N, <<>>}, amqtt_proto:decode_remaining_length(Encoded)) + end, + Ns + ). + +%% ------------------------------------------------------------------- +%% CONNECT +%% ------------------------------------------------------------------- + +encode_minimal_connect_test() -> + Packet = iolist_to_binary(amqtt_proto:encode_connect(#{client_id => <<"test">>})), + {ok, {connect, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"test">>, maps:get(client_id, Data)), + ?assertEqual(60, maps:get(keep_alive_seconds, Data)), + ?assertEqual(true, maps:get(clean_session, Data)), + ?assertNot(maps:is_key(username, Data)), + ?assertNot(maps:is_key(password, Data)), + ?assertNot(maps:is_key(will_topic, Data)). + +encode_connect_protocol_header_test() -> + Packet = iolist_to_binary(amqtt_proto:encode_connect(#{client_id => <<"c">>})), + %% Fixed header: type=1, flags=0 -> 0x10 + %% Then remaining length, then protocol name "MQTT", level 4 + <<16#10, _RL, 0, 4, Rest/binary>> = Packet, + <<"MQTT", 4, _FlagsAndRest/binary>> = Rest, + ok. + +encode_connect_with_username_password_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_connect(#{ + client_id => <<"c">>, + username => <<"user">>, + password => <<"pass">> + }) + ), + {ok, {connect, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"user">>, maps:get(username, Data)), + ?assertEqual(<<"pass">>, maps:get(password, Data)). + +encode_connect_with_will_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_connect(#{ + client_id => <<"c">>, + will_topic => <<"/bye">>, + will_message => <<"gone">>, + will_qos => 1, + will_retain => true + }) + ), + {ok, {connect, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"/bye">>, maps:get(will_topic, Data)), + ?assertEqual(<<"gone">>, maps:get(will_message, Data)), + ?assertEqual(1, maps:get(will_qos, Data)), + ?assertEqual(true, maps:get(will_retain, Data)). + +encode_connect_clean_session_false_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_connect(#{client_id => <<"c">>, clean_session => false}) + ), + {ok, {connect, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(false, maps:get(clean_session, Data)). + +encode_connect_custom_keep_alive_seconds_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_connect(#{client_id => <<"c">>, keep_alive_seconds => 120}) + ), + {ok, {connect, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(120, maps:get(keep_alive_seconds, Data)). + +%% ------------------------------------------------------------------- +%% CONNACK +%% ------------------------------------------------------------------- + +decode_connack_success_test() -> + Packet = <<16#20, 2, 0, 0>>, + ?assertMatch( + {ok, {connack, #{session_present := false, return_code := 0}}, <<>>}, + amqtt_proto:decode(Packet) + ). + +decode_connack_session_present_test() -> + Packet = <<16#20, 2, 1, 0>>, + ?assertMatch( + {ok, {connack, #{session_present := true, return_code := 0}}, <<>>}, + amqtt_proto:decode(Packet) + ). + +decode_connack_not_authorized_test() -> + Packet = <<16#20, 2, 0, 5>>, + ?assertMatch( + {ok, {connack, #{session_present := false, return_code := 5}}, <<>>}, + amqtt_proto:decode(Packet) + ). + +%% ------------------------------------------------------------------- +%% PUBLISH +%% ------------------------------------------------------------------- + +encode_decode_publish_qos0_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{topic => <<"t">>, message => <<"m">>, qos => 0}) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"t">>, maps:get(topic, Data)), + ?assertEqual(<<"m">>, maps:get(message, Data)), + ?assertEqual(0, maps:get(qos, Data)), + ?assertEqual(false, maps:get(dup, Data)), + ?assertEqual(false, maps:get(retain, Data)), + ?assertNot(maps:is_key(packet_id, Data)). + +encode_decode_publish_qos1_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"t">>, message => <<"m">>, qos => 1, packet_id => 42 + }) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"t">>, maps:get(topic, Data)), + ?assertEqual(<<"m">>, maps:get(message, Data)), + ?assertEqual(1, maps:get(qos, Data)), + ?assertEqual(42, maps:get(packet_id, Data)). + +encode_decode_publish_qos2_dup_retain_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"a/b">>, + message => <<"hello">>, + qos => 2, + packet_id => 1000, + dup => true, + retain => true + }) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(2, maps:get(qos, Data)), + ?assertEqual(true, maps:get(dup, Data)), + ?assertEqual(true, maps:get(retain, Data)), + ?assertEqual(1000, maps:get(packet_id, Data)), + ?assertEqual(<<"a/b">>, maps:get(topic, Data)), + ?assertEqual(<<"hello">>, maps:get(message, Data)). + +encode_decode_publish_empty_message_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{topic => <<"t">>, message => <<>>, qos => 0}) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<>>, maps:get(message, Data)). + +encode_decode_publish_large_message_test() -> + BigMsg = binary:copy(<<"x">>, 200), + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{topic => <<"t">>, message => BigMsg, qos => 0}) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(BigMsg, maps:get(message, Data)). + +%% ------------------------------------------------------------------- +%% PUBACK / PUBREC / PUBREL / PUBCOMP +%% ------------------------------------------------------------------- + +encode_decode_puback_test() -> + Packet = amqtt_proto:encode_puback(7), + ?assertEqual(<<16#40, 2, 0, 7>>, Packet), + ?assertMatch({ok, {puback, #{packet_id := 7}}, <<>>}, amqtt_proto:decode(Packet)). + +encode_decode_pubrec_test() -> + Packet = amqtt_proto:encode_pubrec(99), + ?assertEqual(<<16#50, 2, 0, 99>>, Packet), + ?assertMatch({ok, {pubrec, #{packet_id := 99}}, <<>>}, amqtt_proto:decode(Packet)). + +encode_decode_pubrel_reserved_flags_test() -> + Packet = amqtt_proto:encode_pubrel(99), + %% PUBREL type=6, flags=2 -> 0x62 + ?assertEqual(<<16#62, 2, 0, 99>>, Packet), + ?assertMatch({ok, {pubrel, #{packet_id := 99}}, <<>>}, amqtt_proto:decode(Packet)). + +encode_decode_pubcomp_test() -> + Packet = amqtt_proto:encode_pubcomp(99), + ?assertEqual(<<16#70, 2, 0, 99>>, Packet), + ?assertMatch({ok, {pubcomp, #{packet_id := 99}}, <<>>}, amqtt_proto:decode(Packet)). + +%% ------------------------------------------------------------------- +%% SUBSCRIBE / SUBACK +%% ------------------------------------------------------------------- + +encode_decode_subscribe_single_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_subscribe(#{packet_id => 1, topics => [{<<"a/b">>, 1}]}) + ), + {ok, {subscribe, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(1, maps:get(packet_id, Data)), + ?assertEqual([{<<"a/b">>, 1}], maps:get(topics, Data)). + +encode_decode_subscribe_multiple_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_subscribe(#{ + packet_id => 5, + topics => [{<<"a">>, 0}, {<<"b">>, 1}, {<<"c">>, 2}] + }) + ), + {ok, {subscribe, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(5, maps:get(packet_id, Data)), + ?assertEqual([{<<"a">>, 0}, {<<"b">>, 1}, {<<"c">>, 2}], maps:get(topics, Data)). + +subscribe_reserved_flags_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_subscribe(#{packet_id => 1, topics => [{<<"t">>, 0}]}) + ), + %% SUBSCRIBE type=8, flags=2 -> 0x82 + <<16#82, _Rest/binary>> = Packet, + ok. + +decode_suback_test() -> + %% SUBACK: type=9, flags=0 -> 0x90, remaining=5, packet_id=1, then 3 return codes + Packet = <<16#90, 5, 0, 1, 0, 1, 16#80>>, + {ok, {suback, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(1, maps:get(packet_id, Data)), + ?assertEqual([0, 1, 16#80], maps:get(return_codes, Data)). + +%% ------------------------------------------------------------------- +%% UNSUBSCRIBE / UNSUBACK +%% ------------------------------------------------------------------- + +encode_decode_unsubscribe_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_unsubscribe(#{packet_id => 3, topics => [<<"a">>, <<"b">>]}) + ), + {ok, {unsubscribe, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(3, maps:get(packet_id, Data)), + ?assertEqual([<<"a">>, <<"b">>], maps:get(topics, Data)). + +unsubscribe_reserved_flags_test() -> + Packet = iolist_to_binary( + amqtt_proto:encode_unsubscribe(#{packet_id => 1, topics => [<<"t">>]}) + ), + <<16#A2, _Rest/binary>> = Packet, + ok. + +decode_unsuback_test() -> + Packet = <<16#B0, 2, 0, 3>>, + ?assertMatch({ok, {unsuback, #{packet_id := 3}}, <<>>}, amqtt_proto:decode(Packet)). + +%% ------------------------------------------------------------------- +%% PINGREQ / PINGRESP +%% ------------------------------------------------------------------- + +encode_pingreq_test() -> + ?assertEqual(<<16#C0, 0>>, amqtt_proto:encode_pingreq()). + +decode_pingresp_test() -> + ?assertMatch({ok, {pingresp, #{}}, <<>>}, amqtt_proto:decode(<<16#D0, 0>>)). + +decode_pingreq_test() -> + ?assertMatch({ok, {pingreq, #{}}, <<>>}, amqtt_proto:decode(<<16#C0, 0>>)). + +%% ------------------------------------------------------------------- +%% DISCONNECT +%% ------------------------------------------------------------------- + +encode_disconnect_test() -> + ?assertEqual(<<16#E0, 0>>, amqtt_proto:encode_disconnect()). + +decode_disconnect_test() -> + ?assertMatch({ok, {disconnect, #{}}, <<>>}, amqtt_proto:decode(<<16#E0, 0>>)). + +%% ------------------------------------------------------------------- +%% Fragmentation / Buffer Handling +%% ------------------------------------------------------------------- + +decode_incomplete_empty_test() -> + ?assertEqual({error, incomplete}, amqtt_proto:decode(<<>>)). + +decode_incomplete_only_fixed_header_test() -> + ?assertEqual({error, incomplete}, amqtt_proto:decode(<<16#20>>)). + +decode_incomplete_remaining_length_says_more_test() -> + %% CONNACK header but missing payload + ?assertEqual({error, incomplete}, amqtt_proto:decode(<<16#20, 2, 0>>)). + +decode_incomplete_variable_length_test() -> + ?assertEqual({error, incomplete}, amqtt_proto:decode(<<16#30, 128>>)). + +decode_two_packets_concatenated_test() -> + P1 = <<16#D0, 0>>, + P2 = <<16#D0, 0>>, + Combined = <>, + {ok, {pingresp, _}, Rest} = amqtt_proto:decode(Combined), + ?assertMatch({ok, {pingresp, _}, <<>>}, amqtt_proto:decode(Rest)). + +decode_connack_with_trailing_publish_test() -> + Connack = <<16#20, 2, 0, 0>>, + Publish = iolist_to_binary( + amqtt_proto:encode_publish(#{topic => <<"t">>, message => <<"m">>, qos => 0}) + ), + Combined = <>, + {ok, {connack, _}, Rest} = amqtt_proto:decode(Combined), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Rest), + ?assertEqual(<<"t">>, maps:get(topic, Data)). + +large_publish_roundtrip_test() -> + %% Message > 16383 bytes requires 3-byte remaining length + BigMsg = binary:copy(<<"A">>, 20000), + Packet = iolist_to_binary( + amqtt_proto:encode_publish(#{ + topic => <<"sensor/data">>, + message => BigMsg, + qos => 1, + packet_id => 65535 + }) + ), + {ok, {publish, Data}, <<>>} = amqtt_proto:decode(Packet), + ?assertEqual(<<"sensor/data">>, maps:get(topic, Data)), + ?assertEqual(BigMsg, maps:get(message, Data)), + ?assertEqual(65535, maps:get(packet_id, Data)). + +%% ------------------------------------------------------------------- +%% Decoder negative tests: malformed flags, reserved QoS, packet ID 0 +%% ------------------------------------------------------------------- + +decode_publish_qos3_rejected_test() -> + %% PUBLISH (type=3) with QoS=3 (bits 2-1 == 11): flags = 0b0110 = 6 + %% Build a packet manually: <<0x36, RL, ... topic ... pid ... msg>> + Packet = <<16#36, 5, 0, 1, "t", 0, 1>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_publish_qos1_pid0_rejected_test() -> + %% PUBLISH QoS 1 with packet_id = 0 + Packet = <<16#32, 5, 0, 1, "t", 0, 0>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_puback_pid0_rejected_test() -> + Packet = <<16#40, 2, 0, 0>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_pubrel_bad_flags_test() -> + %% PUBREL must have flags 0x02; using 0x00 must be rejected + Packet = <<16#60, 2, 0, 7>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_subscribe_bad_flags_test() -> + %% SUBSCRIBE must have flags 0x02; using 0x00 must be rejected + Packet = <<16#80, 6, 0, 1, 0, 1, "t", 0>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_unsubscribe_bad_flags_test() -> + %% UNSUBSCRIBE must have flags 0x02 + Packet = <<16#A0, 5, 0, 1, 0, 1, "t">>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_puback_bad_flags_test() -> + %% PUBACK must have flags 0x00; non-zero must be rejected + Packet = <<16#41, 2, 0, 1>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_connack_reserved_bits_set_test() -> + %% MQTT 3.1.1 §3.2.2.1: AckFlags bits 7..1 are reserved and must be 0. + Packet = <<16#20, 2, 16#80, 0>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_connack_invalid_return_code_test() -> + %% Return code 6 is outside the spec range 0..5. + Packet = <<16#20, 2, 0, 6>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_connack_session_present_with_error_test() -> + %% MQTT 3.1.1 §3.2.2.2: session_present must be 0 when return_code /= 0. + Packet = <<16#20, 2, 1, 4>>, + ?assertMatch( + {error, {protocol_error, {bad_connack, session_present_with_error}}}, + amqtt_proto:decode(Packet) + ). + +decode_suback_empty_return_codes_test() -> + %% MQTT 3.1.1 §3.9: SUBACK must contain at least one return code. + Packet = <<16#90, 2, 0, 1>>, + ?assertMatch({error, {protocol_error, _}}, amqtt_proto:decode(Packet)). + +decode_suback_invalid_return_code_test() -> + %% MQTT 3.1.1 §3.9.3: each return code must be 0, 1, 2, or 0x80. + Packet = <<16#90, 3, 0, 1, 16#03>>, + ?assertMatch( + {error, {protocol_error, {bad_suback, return_codes}}}, + amqtt_proto:decode(Packet) + ). diff --git a/e2e/client_pub_sub_e2e/README.md b/e2e/client_pub_sub_e2e/README.md new file mode 100644 index 0000000..95e1508 --- /dev/null +++ b/e2e/client_pub_sub_e2e/README.md @@ -0,0 +1,97 @@ + + +# client_pub_sub_e2e + +End-to-end smoke test for [amqtt_client](../../amqtt_client/). + +## What it does + +Spins up two `amqtt_client` instances against the public broker +`test.mosquitto.org`: + +- **Client A**: plain TCP, port 1883. +- **Client B**: TLS, port 8883, `ssl_opts => [{verify, verify_none}]` (the + only verify mode AtomVM's `ssl` supports today). + +They share a randomly-generated topic root and run **3 iterations at each of +QoS 0, 1, and 2** (= 9 round-trips). For each iteration A publishes a 32-byte +random payload to `/payloads`; B receives it, computes the SHA-256 hex +digest, publishes that to `/replies`; A verifies the digest matches. + +Outputs `ok qos=… iter=…` per iteration and `PASS (9/9)` at the end (or +`FAIL: …` on any timeout / mismatch). + +## Build + +`amqtt_client` is wired in as a relative-path dependency +(`{path, "../../amqtt_client"}` via the `rebar3_path_deps` plugin), so a +plain `rebar3 atomvm packbeam` is enough: + +```sh +rebar3 atomvm packbeam +``` + +This produces: +- `_build/default/lib/client_pub_sub_e2e.avm` (the entry app) +- `_build/default/lib/amqtt_client.avm` (the library) + +## Run + +The AtomVM binary takes the entry .avm followed by any number of library +.avms. With `$ATOMVM` pointing at your AtomVM checkout root: + +```sh +$ATOMVM/build/src/AtomVM \ + e2e/client_pub_sub_e2e/_build/default/lib/client_pub_sub_e2e.avm \ + e2e/client_pub_sub_e2e/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` + +Expected (~5–15 s with a healthy network): + +``` +topic root: amqtt_e2e/ +client A (TCP) connected +client B (TLS) connected +subscriptions installed +ok qos=0 iter=1 +ok qos=0 iter=2 +ok qos=0 iter=3 +ok qos=1 iter=1 +… +ok qos=2 iter=3 +PASS (9/9) +``` + +## Network requirements + +### Verifying broker reachability + +```sh +nc -vz test.mosquitto.org 1883 # expect "succeeded" +nc -vz test.mosquitto.org 8883 # expect "succeeded" +``` + +If either fails, switch to a local broker. + +### Local broker (Mosquitto) + +```sh +# Plain TCP (no extra config) +mosquitto -p 1883 +# In another terminal, with TLS using a self-signed cert: +mosquitto -c mosquitto-tls.conf +``` + +Then edit `src/client_pub_sub_e2e.erl`: + +```erlang +-define(BROKER, "127.0.0.1"). +``` + +and rebuild. The `verify_none` ssl_opt the test already uses lets you connect +to a self-signed broker without setting up CA chains. diff --git a/e2e/client_pub_sub_e2e/rebar.config b/e2e/client_pub_sub_e2e/rebar.config new file mode 100644 index 0000000..126d88f --- /dev/null +++ b/e2e/client_pub_sub_e2e/rebar.config @@ -0,0 +1,41 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, client_pub_sub_e2e}, prune]} +]}. + +{shell, [ + {apps, [client_pub_sub_e2e]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/e2e/client_pub_sub_e2e/rebar.lock b/e2e/client_pub_sub_e2e/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/e2e/client_pub_sub_e2e/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/e2e/client_pub_sub_e2e/rebar.lock.license b/e2e/client_pub_sub_e2e/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/e2e/client_pub_sub_e2e/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.app.src b/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.app.src new file mode 100644 index 0000000..694612c --- /dev/null +++ b/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, client_pub_sub_e2e, [ + {description, "End-to-end smoke test for amqtt_client on AtomVM"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.erl b/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.erl new file mode 100644 index 0000000..b723f1c --- /dev/null +++ b/e2e/client_pub_sub_e2e/src/client_pub_sub_e2e.erl @@ -0,0 +1,175 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(client_pub_sub_e2e). + +-export([start/0]). + +-define(TCP_PORT, 1883). +-define(TLS_PORT, 8883). +-define(RECV_TIMEOUT, 15000). +-define(ITERS_PER_QOS, 3). + +broker() -> + case os:getenv("AMQTT_E2E_BROKER") of + false -> "127.0.0.1"; + Host -> Host + end. + +use_tls() -> + case os:getenv("AMQTT_E2E_USE_TLS") of + "true" -> true; + "1" -> true; + _ -> false + end. + +start() -> + try run() of + ok -> + io:format("PASS (9/9)~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + Broker = broker(), + UseTls = use_tls(), + + Suffix = binary:encode_hex(crypto:strong_rand_bytes(8), lowercase), + Root = <<"amqtt_e2e/", Suffix/binary>>, + PubTopic = <>, + ReplyTopic = <>, + + AClientId = <<"amqtt_e2e_a_", Suffix/binary>>, + BClientId = <<"amqtt_e2e_b_", Suffix/binary>>, + + io:format("topic root: ~s~n", [Root]), + + io:format("resolving ~s ...~n", [Broker]), + {ok, BrokerIp} = inet:getaddr(Broker, inet), + io:format("broker ip: ~p~n", [BrokerIp]), + + io:format("connecting A (TCP) ...~n"), + A = connect_tcp(AClientId, BrokerIp), + wait_connack(A), + io:format("client A (TCP) connected~n"), + + BLabel = client_b_label(UseTls), + io:format("connecting B (~s) ...~n", [BLabel]), + B = connect_b(UseTls, BClientId, BrokerIp, Broker), + wait_connack(B), + io:format("client B (~s) connected~n", [BLabel]), + + {ok, _} = amqtt_client:subscribe(A, [{ReplyTopic, 2}]), + {ok, _} = amqtt_client:subscribe(B, [{PubTopic, 2}]), + io:format("subscriptions installed~n"), + + try + lists:foreach( + fun(QoS) -> + lists:foreach( + fun(Iter) -> + round_trip(QoS, Iter, A, B, PubTopic, ReplyTopic) + end, + lists:seq(1, ?ITERS_PER_QOS) + ) + end, + [0, 1, 2] + ) + after + try + amqtt_client:disconnect(A) + catch + _:_ -> ok + end, + try + amqtt_client:disconnect(B) + catch + _:_ -> ok + end + end, + ok. + +connect_tcp(ClientId, BrokerIp) -> + {ok, Pid} = amqtt_client:connect(#{ + host => BrokerIp, + port => ?TCP_PORT, + client_id => ClientId, + keep_alive_seconds => 60 + }), + Pid. + +client_b_label(true) -> "TLS"; +client_b_label(false) -> "TCP". + +connect_b(true, ClientId, _BrokerIp, BrokerHost) -> + {ok, Pid} = amqtt_client:connect(#{ + host => BrokerHost, + port => ?TLS_PORT, + transport => ssl, + ssl_opts => [{verify, verify_none}], + client_id => ClientId, + keep_alive_seconds => 60 + }), + Pid; +connect_b(false, ClientId, BrokerIp, _BrokerHost) -> + connect_tcp(ClientId, BrokerIp). + +wait_connack(Pid) -> + receive + {mqtt, Pid, connack, #{return_code := 0}} -> + ok; + {mqtt, Pid, connack, #{return_code := RC}} -> + erlang:error({connack_refused, RC}) + after ?RECV_TIMEOUT -> + erlang:error({connack_timeout, Pid}) + end. + +round_trip(QoS, Iter, A, B, PubTopic, ReplyTopic) -> + Payload = crypto:strong_rand_bytes(32), + ExpectedSha = binary:encode_hex(crypto:hash(sha256, Payload), lowercase), + + publish(A, PubTopic, Payload, QoS), + + receive + {mqtt, B, publish, #{message := P}} when P =:= Payload -> + Sha = binary:encode_hex(crypto:hash(sha256, P), lowercase), + publish(B, ReplyTopic, Sha, QoS) + after ?RECV_TIMEOUT -> + erlang:error({b_timeout, QoS, Iter}) + end, + + receive + {mqtt, A, publish, #{message := Reply}} -> + case Reply of + ExpectedSha -> + io:format("ok qos=~p iter=~p~n", [QoS, Iter]); + _ -> + erlang:error({sha_mismatch, QoS, Iter, ExpectedSha, Reply}) + end + after ?RECV_TIMEOUT -> + erlang:error({a_timeout, QoS, Iter}) + end. + +publish(Client, Topic, Payload, 0) -> + ok = amqtt_client:publish(Client, Topic, Payload, 0); +publish(Client, Topic, Payload, QoS) -> + {ok, _PacketId} = amqtt_client:publish(Client, Topic, Payload, QoS). diff --git a/examples/mqtt_publish_example/README.md b/examples/mqtt_publish_example/README.md new file mode 100644 index 0000000..e0812fe --- /dev/null +++ b/examples/mqtt_publish_example/README.md @@ -0,0 +1,26 @@ + + +# mqtt_publish_example + +Connects anonymously to `test.mosquitto.org:1883` and publishes +`"Hello World"` to topic `amqtt/greet` at QoS 1, then disconnects. + +## Run on Erlang/OTP + +```sh +rebar3 shell --eval "mqtt_publish_example:start(), init:stop(0)." +``` + +## Run on AtomVM + +```sh +rebar3 atomvm packbeam +$ATOMVM/build/src/AtomVM \ + examples/mqtt_publish_example/_build/default/lib/mqtt_publish_example.avm \ + examples/mqtt_publish_example/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` diff --git a/examples/mqtt_publish_example/rebar.config b/examples/mqtt_publish_example/rebar.config new file mode 100644 index 0000000..c456846 --- /dev/null +++ b/examples/mqtt_publish_example/rebar.config @@ -0,0 +1,45 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, mqtt_publish_example}, prune]} +]}. + +{shell, [ + {apps, [mqtt_publish_example]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ssl, amqtt_client]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/examples/mqtt_publish_example/rebar.lock b/examples/mqtt_publish_example/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/examples/mqtt_publish_example/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/examples/mqtt_publish_example/rebar.lock.license b/examples/mqtt_publish_example/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/examples/mqtt_publish_example/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/examples/mqtt_publish_example/src/mqtt_publish_example.app.src b/examples/mqtt_publish_example/src/mqtt_publish_example.app.src new file mode 100644 index 0000000..8a87f38 --- /dev/null +++ b/examples/mqtt_publish_example/src/mqtt_publish_example.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, mqtt_publish_example, [ + {description, "Hello-world MQTT publish example"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/examples/mqtt_publish_example/src/mqtt_publish_example.erl b/examples/mqtt_publish_example/src/mqtt_publish_example.erl new file mode 100644 index 0000000..9db34f8 --- /dev/null +++ b/examples/mqtt_publish_example/src/mqtt_publish_example.erl @@ -0,0 +1,68 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(mqtt_publish_example). + +-export([start/0]). + +-define(BROKER, "test.mosquitto.org"). +-define(TCP_PORT, 1883). +-define(TOPIC, <<"amqtt/greet">>). +-define(MESSAGE, <<"Hello World">>). +-define(RECV_TIMEOUT, 15000). + +start() -> + try run() of + ok -> + io:format("PASS~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + %% AtomVM's gen_tcp can hang on slow-resolving hostnames; resolve to IPv4 + %% ourselves. + {ok, BrokerIp} = inet:getaddr(?BROKER, inet), + io:format("connecting to ~s (~p):~p~n", [?BROKER, BrokerIp, ?TCP_PORT]), + + {ok, Client} = amqtt_client:connect(#{ + host => BrokerIp, + port => ?TCP_PORT, + client_id => client_id(), + keep_alive_seconds => 60 + }), + receive + {mqtt, Client, connack, #{return_code := 0}} -> + ok + after ?RECV_TIMEOUT -> + erlang:error(connack_timeout) + end, + io:format("connected~n"), + + {ok, _PacketId} = amqtt_client:publish(Client, ?TOPIC, ?MESSAGE, 1), + io:format("published ~p to ~p~n", [?MESSAGE, ?TOPIC]), + + ok = amqtt_client:disconnect(Client), + ok. + +client_id() -> + Suffix = integer_to_binary(erlang:system_time(millisecond)), + <<"amqtt_pub_", Suffix/binary>>. diff --git a/examples/mqtt_qos2_subscribe/README.md b/examples/mqtt_qos2_subscribe/README.md new file mode 100644 index 0000000..185c145 --- /dev/null +++ b/examples/mqtt_qos2_subscribe/README.md @@ -0,0 +1,41 @@ + + +# mqtt_qos2_subscribe + +Demonstrates a QoS 2 subscriber with a **persistent session** and +**manual ACK**: + +- Connects with `clean_session => false` and a stable `client_id` so the + broker recognises the session across reconnects. On CONNACK, prints + `session_present: true|false`. +- Subscribes to `amqtt/amqtt_qos2_demo/#` at QoS 2. +- Receives up to 10 messages (or 60 s deadline) and **manually + acknowledges** each one via `amqtt_client:ack/2`. The broker's PUBREL -> + PUBCOMP second leg is auto-handled by the gen_server. + +To exercise it, publish from another terminal while this example is +running: + +```sh +mosquitto_pub -h test.mosquitto.org -t amqtt/amqtt_qos2_demo/test -m "hello qos2" -q 2 +``` + +## Run on Erlang/OTP + +```sh +rebar3 shell --eval "mqtt_qos2_subscribe:start(), init:stop(0)." +``` + +## Run on AtomVM + +```sh +rebar3 atomvm packbeam +$ATOMVM/build/src/AtomVM \ + examples/mqtt_qos2_subscribe/_build/default/lib/mqtt_qos2_subscribe.avm \ + examples/mqtt_qos2_subscribe/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` diff --git a/examples/mqtt_qos2_subscribe/rebar.config b/examples/mqtt_qos2_subscribe/rebar.config new file mode 100644 index 0000000..e6f7e96 --- /dev/null +++ b/examples/mqtt_qos2_subscribe/rebar.config @@ -0,0 +1,45 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, mqtt_qos2_subscribe}, prune]} +]}. + +{shell, [ + {apps, [mqtt_qos2_subscribe]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ssl, amqtt_client]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/examples/mqtt_qos2_subscribe/rebar.lock b/examples/mqtt_qos2_subscribe/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/examples/mqtt_qos2_subscribe/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/examples/mqtt_qos2_subscribe/rebar.lock.license b/examples/mqtt_qos2_subscribe/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/examples/mqtt_qos2_subscribe/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.app.src b/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.app.src new file mode 100644 index 0000000..120ee1e --- /dev/null +++ b/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, mqtt_qos2_subscribe, [ + {description, "QoS 2 subscribe with persistent session and manual ACK"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.erl b/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.erl new file mode 100644 index 0000000..dce9910 --- /dev/null +++ b/examples/mqtt_qos2_subscribe/src/mqtt_qos2_subscribe.erl @@ -0,0 +1,98 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(mqtt_qos2_subscribe). + +-export([start/0]). + +-define(BROKER, "test.mosquitto.org"). +-define(TCP_PORT, 1883). +%% Stable client_id is required for the broker to recognise us as the same +%% session across reconnects when clean_session=false. +-define(CLIENT_ID, <<"amqtt_qos2_demo">>). +-define(TOPIC, <<"amqtt/amqtt_qos2_demo/#">>). +-define(MAX_MESSAGES, 10). +-define(DEADLINE_MS, 60000). +-define(RECV_TIMEOUT, 15000). + +start() -> + try run() of + ok -> + io:format("PASS~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + io:format("connecting to ~s:~p~n", [?BROKER, ?TCP_PORT]), + + {ok, Client} = amqtt_client:connect(#{ + host => ?BROKER, + port => ?TCP_PORT, + client_id => ?CLIENT_ID, + clean_session => false, + auto_ack => false, + keep_alive_seconds => 60 + }), + SessionPresent = + receive + {mqtt, Client, connack, #{return_code := 0, session_present := SP}} -> + SP + after ?RECV_TIMEOUT -> + erlang:error(connack_timeout) + end, + io:format("connected, session_present: ~p~n", [SessionPresent]), + + {ok, _} = amqtt_client:subscribe(Client, [{?TOPIC, 2}]), + io:format("subscribed to ~s at QoS 2 (publish here from another client)~n", [?TOPIC]), + io:format("waiting for up to ~p messages or ~p ms...~n", [?MAX_MESSAGES, ?DEADLINE_MS]), + + Deadline = erlang:monotonic_time(millisecond) + ?DEADLINE_MS, + Received = receive_loop(Client, 0, Deadline), + + try + amqtt_client:disconnect(Client) + catch + _:_ -> ok + end, + io:format("processed ~p QoS 2 message(s)~n", [Received]), + ok. + +receive_loop(_Client, Count, _Deadline) when Count >= ?MAX_MESSAGES -> + Count; +receive_loop(Client, Count, Deadline) -> + Now = erlang:monotonic_time(millisecond), + Remaining = Deadline - Now, + case Remaining =< 0 of + true -> + Count; + false -> + receive + {mqtt, Client, publish, #{ + topic := Topic, message := Payload, packet_id := PacketId, qos := 2 + }} -> + io:format("[QoS2] ~s: ~p (packet_id=~p)~n", [Topic, Payload, PacketId]), + ok = amqtt_client:ack(Client, PacketId), + receive_loop(Client, Count + 1, Deadline) + after Remaining -> + Count + end + end. diff --git a/examples/mqtt_subscribe_example/README.md b/examples/mqtt_subscribe_example/README.md new file mode 100644 index 0000000..197d672 --- /dev/null +++ b/examples/mqtt_subscribe_example/README.md @@ -0,0 +1,31 @@ + + +# mqtt_subscribe_example + +Connects to `test.mosquitto.org:1883` with username `wildcard` (no password), +subscribes to `#`, and prints each incoming message as +`: ` until 100 messages have been received or 60 +seconds have elapsed -- whichever comes first. + +The `wildcard` user is the standard test.mosquitto.org account that allows +a `#` subscription for 20 seconds for the purpose of topic discovery. + +## Run on Erlang/OTP + +```sh +rebar3 shell --eval "mqtt_subscribe_example:start(), init:stop(0)." +``` + +## Run on AtomVM + +```sh +rebar3 atomvm packbeam +$ATOMVM/build/src/AtomVM \ + examples/mqtt_subscribe_example/_build/default/lib/mqtt_subscribe_example.avm \ + examples/mqtt_subscribe_example/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` diff --git a/examples/mqtt_subscribe_example/rebar.config b/examples/mqtt_subscribe_example/rebar.config new file mode 100644 index 0000000..00068c6 --- /dev/null +++ b/examples/mqtt_subscribe_example/rebar.config @@ -0,0 +1,45 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, mqtt_subscribe_example}, prune]} +]}. + +{shell, [ + {apps, [mqtt_subscribe_example]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ssl, amqtt_client]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/examples/mqtt_subscribe_example/rebar.lock b/examples/mqtt_subscribe_example/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/examples/mqtt_subscribe_example/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/examples/mqtt_subscribe_example/rebar.lock.license b/examples/mqtt_subscribe_example/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/examples/mqtt_subscribe_example/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.app.src b/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.app.src new file mode 100644 index 0000000..8c63ac5 --- /dev/null +++ b/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, mqtt_subscribe_example, [ + {description, "MQTT wildcard subscribe example (root topic, base64 print)"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.erl b/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.erl new file mode 100644 index 0000000..a8af662 --- /dev/null +++ b/examples/mqtt_subscribe_example/src/mqtt_subscribe_example.erl @@ -0,0 +1,100 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(mqtt_subscribe_example). + +-export([start/0]). + +-define(BROKER, "test.mosquitto.org"). +-define(TCP_PORT, 1883). +%% Per https://test.mosquitto.org/, anonymous clients can't subscribe to the +%% literal `#` topic. Connecting with username `wildcard' (no password) lets +%% the broker accept a `#' subscription for 20 seconds before auto-removing +%% it -- intended for topic discovery. +-define(USER, <<"wildcard">>). +-define(MAX_MESSAGES, 100). +-define(DEADLINE_MS, 60000). +-define(RECV_TIMEOUT, 15000). + +start() -> + try run() of + ok -> + io:format("PASS~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + {ok, BrokerIp} = inet:getaddr(?BROKER, inet), + io:format("connecting to ~s (~p):~p as ~s~n", [?BROKER, BrokerIp, ?TCP_PORT, ?USER]), + + {ok, Client} = amqtt_client:connect(#{ + host => BrokerIp, + port => ?TCP_PORT, + username => ?USER, + client_id => client_id(), + keep_alive_seconds => 60 + }), + receive + {mqtt, Client, connack, #{return_code := 0}} -> ok + after ?RECV_TIMEOUT -> + erlang:error(connack_timeout) + end, + io:format("connected~n"), + + {ok, _} = amqtt_client:subscribe(Client, [{<<"#">>, 0}]), + io:format( + "subscribed to #, collecting up to ~p messages or ~p ms...~n", + [?MAX_MESSAGES, ?DEADLINE_MS] + ), + + Deadline = erlang:monotonic_time(millisecond) + ?DEADLINE_MS, + Received = receive_loop(Client, 0, Deadline), + + try + amqtt_client:disconnect(Client) + catch + _:_ -> ok + end, + io:format("received ~p message(s)~n", [Received]), + ok. + +receive_loop(_Client, Count, _Deadline) when Count >= ?MAX_MESSAGES -> + Count; +receive_loop(Client, Count, Deadline) -> + Now = erlang:monotonic_time(millisecond), + Remaining = Deadline - Now, + case Remaining =< 0 of + true -> + Count; + false -> + receive + {mqtt, Client, publish, #{topic := Topic, message := Payload}} -> + io:format("~s: ~s~n", [Topic, base64:encode(Payload)]), + receive_loop(Client, Count + 1, Deadline) + after Remaining -> + Count + end + end. + +client_id() -> + Suffix = integer_to_binary(erlang:system_time(millisecond)), + <<"amqtt_sub_", Suffix/binary>>. diff --git a/examples/mqtt_subscribe_json_example/README.md b/examples/mqtt_subscribe_json_example/README.md new file mode 100644 index 0000000..aa5ae10 --- /dev/null +++ b/examples/mqtt_subscribe_json_example/README.md @@ -0,0 +1,31 @@ + + +# mqtt_subscribe_json_example + +Connects to `test.mosquitto.org:1884` (the authenticated listener) as +`ro` / `readonly`, subscribes to `#`, and runs an infinite loop printing +each message that successfully decodes as JSON. Non-JSON payloads are +silently ignored. + +Uses `json:decode/1`: present on Erlang/OTP 27+ and on AtomVM +(`AtomVM/libs/estdlib/src/json.erl`, same API). + +## Run on Erlang/OTP + +```sh +rebar3 shell --eval "mqtt_subscribe_json_example:start(), init:stop(0)." +``` + +## Run on AtomVM + +```sh +rebar3 atomvm packbeam +$ATOMVM/build/src/AtomVM \ + examples/mqtt_subscribe_json_example/_build/default/lib/mqtt_subscribe_json_example.avm \ + examples/mqtt_subscribe_json_example/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` diff --git a/examples/mqtt_subscribe_json_example/rebar.config b/examples/mqtt_subscribe_json_example/rebar.config new file mode 100644 index 0000000..decab61 --- /dev/null +++ b/examples/mqtt_subscribe_json_example/rebar.config @@ -0,0 +1,45 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, mqtt_subscribe_json_example}, prune]} +]}. + +{shell, [ + {apps, [mqtt_subscribe_json_example]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ssl, amqtt_client]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/examples/mqtt_subscribe_json_example/rebar.lock b/examples/mqtt_subscribe_json_example/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/examples/mqtt_subscribe_json_example/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/examples/mqtt_subscribe_json_example/rebar.lock.license b/examples/mqtt_subscribe_json_example/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/examples/mqtt_subscribe_json_example/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.app.src b/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.app.src new file mode 100644 index 0000000..be54992 --- /dev/null +++ b/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, mqtt_subscribe_json_example, [ + {description, "MQTT wildcard subscribe example with JSON payload decode"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.erl b/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.erl new file mode 100644 index 0000000..5e80829 --- /dev/null +++ b/examples/mqtt_subscribe_json_example/src/mqtt_subscribe_json_example.erl @@ -0,0 +1,88 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(mqtt_subscribe_json_example). + +-export([start/0]). + +-define(BROKER, "test.mosquitto.org"). +%% Port 1884 is the authenticated unencrypted listener; the `ro' user has +%% read-only access to `#' with no time limit (unlike the `wildcard' user +%% on 1883 whose `#' subscription is auto-removed after 20 s). +-define(TCP_PORT, 1884). +-define(USER, <<"ro">>). +-define(PASSWORD, <<"readonly">>). +-define(RECV_TIMEOUT, 15000). + +start() -> + try run() of + ok -> + io:format("PASS~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + {ok, BrokerIp} = inet:getaddr(?BROKER, inet), + io:format("connecting to ~s (~p):~p as ~s~n", [?BROKER, BrokerIp, ?TCP_PORT, ?USER]), + + {ok, Client} = amqtt_client:connect(#{ + host => BrokerIp, + port => ?TCP_PORT, + username => ?USER, + password => ?PASSWORD, + client_id => client_id(), + keep_alive_seconds => 60 + }), + receive + {mqtt, Client, connack, #{return_code := 0}} -> ok + after ?RECV_TIMEOUT -> + erlang:error(connack_timeout) + end, + io:format("connected~n"), + + {ok, _} = amqtt_client:subscribe(Client, [{<<"#">>, 0}]), + io:format("subscribed to # -- decoding JSON payloads, ignoring others~n"), + + receive_loop(Client). + +receive_loop(Client) -> + receive + {mqtt, Client, publish, #{topic := Topic, message := Payload}} -> + try_decode_print(Topic, Payload), + receive_loop(Client); + {mqtt, Client, disconnected, _} -> + io:format("broker closed connection~n"), + ok + end. + +try_decode_print(Topic, Payload) -> + try json:decode(Payload) of + Decoded -> + io:format("~s: ~p~n", [Topic, Decoded]) + catch + _:_ -> + ok + end. + +client_id() -> + Suffix = integer_to_binary(erlang:system_time(millisecond)), + <<"amqtt_json_sub_", Suffix/binary>>. diff --git a/examples/mqtt_tls_publish_example/README.md b/examples/mqtt_tls_publish_example/README.md new file mode 100644 index 0000000..b4e6fae --- /dev/null +++ b/examples/mqtt_tls_publish_example/README.md @@ -0,0 +1,30 @@ + + +# mqtt_tls_publish_example + +Connects anonymously to `test.mosquitto.org:8883` over TLS and publishes +`"Hello World over TLS"` to topic `amqtt/greet` at QoS 1, then disconnects. + +The TLS configuration uses `{verify, verify_none}` only: the only +verification mode AtomVM's `ssl` module supports today. SNI is +auto-populated from the hostname. + +## Run on Erlang/OTP + +```sh +rebar3 shell --eval "mqtt_tls_publish_example:start(), init:stop(0)." +``` + +## Run on AtomVM + +```sh +rebar3 atomvm packbeam +$ATOMVM/build/src/AtomVM \ + examples/mqtt_tls_publish_example/_build/default/lib/mqtt_tls_publish_example.avm \ + examples/mqtt_tls_publish_example/_build/default/lib/amqtt_client.avm \ + $ATOMVM/build/libs/atomvmlib.avm +``` diff --git a/examples/mqtt_tls_publish_example/rebar.config b/examples/mqtt_tls_publish_example/rebar.config new file mode 100644 index 0000000..1075f78 --- /dev/null +++ b/examples/mqtt_tls_publish_example/rebar.config @@ -0,0 +1,45 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{erl_opts, [debug_info]}. + +{deps, [ + {amqtt_client, {path, "../../amqtt_client"}} +]}. + +{project_plugins, [atomvm_rebar3_plugin, rebar3_path_deps, erlfmt]}. + +{atomvm_rebar3_plugin, [ + {packbeam, [{start, mqtt_tls_publish_example}, prune]} +]}. + +{shell, [ + {apps, [mqtt_tls_publish_example]} +]}. + +{dialyzer, [ + {plt_extra_apps, [ssl, amqtt_client]} +]}. + +{erlfmt, [ + write, + {files, [ + "src/*.{erl,hrl,app.src}", + "rebar.config" + ]} +]}. diff --git a/examples/mqtt_tls_publish_example/rebar.lock b/examples/mqtt_tls_publish_example/rebar.lock new file mode 100644 index 0000000..5982fb7 --- /dev/null +++ b/examples/mqtt_tls_publish_example/rebar.lock @@ -0,0 +1,3 @@ +[{<<"amqtt_client">>, + {path,"../../amqtt_client",{mtime,<<"2026-05-06T09:27:31Z">>}}, + 0}]. diff --git a/examples/mqtt_tls_publish_example/rebar.lock.license b/examples/mqtt_tls_publish_example/rebar.lock.license new file mode 100644 index 0000000..f9c391e --- /dev/null +++ b/examples/mqtt_tls_publish_example/rebar.lock.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: CC0-1.0 +SPDX-FileCopyrightText: Davide Bettio diff --git a/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.app.src b/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.app.src new file mode 100644 index 0000000..33b99b9 --- /dev/null +++ b/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.app.src @@ -0,0 +1,29 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +{application, mqtt_tls_publish_example, [ + {description, "Hello-world MQTT publish example over TLS"}, + {vsn, "0.1.0"}, + {registered, []}, + {applications, [kernel, stdlib]}, + {env, []}, + {modules, []}, + {maintainers, ["Davide Bettio "]}, + {licenses, ["Apache-2.0"]}, + {links, [{"AtomVM", "https://atomvm.org"}]} +]}. diff --git a/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.erl b/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.erl new file mode 100644 index 0000000..cb0899c --- /dev/null +++ b/examples/mqtt_tls_publish_example/src/mqtt_tls_publish_example.erl @@ -0,0 +1,68 @@ +% +% Copyright 2026 Davide Bettio +% +% 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 +% + +-module(mqtt_tls_publish_example). + +-export([start/0]). + +-define(BROKER, "test.mosquitto.org"). +-define(TLS_PORT, 8883). +-define(TOPIC, <<"amqtt/greet">>). +-define(MESSAGE, <<"Hello World over TLS">>). +-define(RECV_TIMEOUT, 15000). + +start() -> + try run() of + ok -> + io:format("PASS~n"), + ok + catch + Class:Reason:Stack -> + io:format("FAIL: ~p:~p ~p~n", [Class, Reason, Stack]), + fail + end. + +run() -> + %% TLS keeps the hostname so SNI works (auto-populated by ssl:connect/3). + io:format("connecting to ~s:~p (TLS)~n", [?BROKER, ?TLS_PORT]), + + {ok, Client} = amqtt_client:connect(#{ + host => ?BROKER, + port => ?TLS_PORT, + transport => ssl, + ssl_opts => [{verify, verify_none}], + client_id => client_id(), + keep_alive_seconds => 60 + }), + receive + {mqtt, Client, connack, #{return_code := 0}} -> + ok + after ?RECV_TIMEOUT -> + erlang:error(connack_timeout) + end, + io:format("connected~n"), + + {ok, _PacketId} = amqtt_client:publish(Client, ?TOPIC, ?MESSAGE, 1), + io:format("published ~p to ~p~n", [?MESSAGE, ?TOPIC]), + + ok = amqtt_client:disconnect(Client), + ok. + +client_id() -> + Suffix = integer_to_binary(erlang:system_time(millisecond)), + <<"amqtt_tls_pub_", Suffix/binary>>.