- Replace deprecated xref:exclude with elixirc_options in vendor/oban_pro - Remove unused require Logger from 8 files - Pin bitstring size variables with ^ in simple_packing, wgrib2, complex_packing, section, nexrad_client, mqtt, and radar worker - Remove dead defp clauses (format/1 nil, depression/2 nil) - Rename Buildings.Parser type record/0 -> building_record/0 to avoid overriding built-in type - Remove redundant catch-all in candidate_detail.ex - Simplify contact_live/show.ex conditional based on type narrowing - Fix DateTime.from_iso8601 return pattern in vendor/oban_web - Upgrade phoenix_live_view to 1.1.31 - Drop --warnings-as-errors from precommit alias; known false positives from @after_verify-generated code in Elixir 1.20.0-rc.6 + PLV 1.1.x - Add credo --strict to precommit as replacement static-analysis gate
188 lines
6.1 KiB
Elixir
188 lines
6.1 KiB
Elixir
defmodule Microwaveprop.Pskr.Mqtt do
|
|
@moduledoc """
|
|
Minimal MQTT 3.1.1 codec — just the packet types this project
|
|
actually exchanges with `mqtt.pskreporter.info:1883`.
|
|
|
|
We're a subscribe-only QoS 0 client, so the wire protocol reduces
|
|
to:
|
|
|
|
* outbound: CONNECT, SUBSCRIBE, PINGREQ, DISCONNECT
|
|
* inbound: CONNACK, PUBLISH (QoS 0), SUBACK, PINGRESP
|
|
|
|
No retained messages, no will, no QoS 1/2 inflight tracking, no
|
|
packet ID reuse logic. Anything beyond that is intentionally not
|
|
implemented — adding a dep just to throw away 80% of its surface
|
|
area felt worse than the ~150 lines below.
|
|
|
|
## Frame layout
|
|
|
|
Every MQTT packet is `<<type::4, flags::4, remaining_length::vbi,
|
|
body::binary>>` where `vbi` is a variable-byte integer (1-4 bytes,
|
|
high bit = "more bytes follow"). `parse/1` splits a TCP buffer
|
|
into the next decoded packet plus any trailing bytes; on a short
|
|
read it returns `{:incomplete, buffer}` so the caller can
|
|
re-invoke after the next chunk arrives.
|
|
|
|
See: https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html
|
|
"""
|
|
|
|
import Bitwise
|
|
|
|
@type packet ::
|
|
{:connack, return_code :: 0..5, session_present :: boolean()}
|
|
| {:publish, topic :: String.t(), payload :: binary()}
|
|
| {:suback, packet_id :: non_neg_integer(), qos_results :: [0..2 | 0x80]}
|
|
| :pingresp
|
|
|
|
# ---------- Outbound builders ----------
|
|
|
|
@doc """
|
|
CONNECT packet for an MQTT 3.1.1 client. Flags are hardcoded to
|
|
`clean_session=1` and no will / no auth — matches the public
|
|
pskreporter broker which doesn't require credentials.
|
|
"""
|
|
@spec connect(String.t(), pos_integer()) :: binary()
|
|
def connect(client_id, keepalive_seconds) when is_binary(client_id) do
|
|
body = <<
|
|
# Variable header
|
|
4::16,
|
|
"MQTT",
|
|
# Protocol level: 3.1.1
|
|
0x04,
|
|
# Connect flags: clean_session
|
|
0x02,
|
|
keepalive_seconds::16,
|
|
# Payload: client ID
|
|
byte_size(client_id)::16,
|
|
client_id::binary
|
|
>>
|
|
|
|
fixed_header(0x10, body)
|
|
end
|
|
|
|
@doc """
|
|
SUBSCRIBE packet. Topics is a list of `{topic_filter, qos}` pairs.
|
|
Returns `{packet_binary, packet_id}` — the caller tracks the
|
|
packet ID for matching the SUBACK on the wire.
|
|
"""
|
|
@spec subscribe(non_neg_integer(), [{String.t(), 0..2}]) :: binary()
|
|
def subscribe(packet_id, topics) when is_list(topics) and topics != [] do
|
|
body =
|
|
IO.iodata_to_binary([
|
|
<<packet_id::16>> | Enum.map(topics, fn {filter, qos} -> <<byte_size(filter)::16, filter::binary, qos>> end)
|
|
])
|
|
|
|
# Bits 3-0 of the SUBSCRIBE first byte are reserved as 0010.
|
|
fixed_header(0x82, body)
|
|
end
|
|
|
|
@doc "PINGREQ keep-alive packet."
|
|
@spec pingreq() :: binary()
|
|
def pingreq, do: <<0xC0, 0x00>>
|
|
|
|
@doc "DISCONNECT packet for clean shutdown."
|
|
@spec disconnect() :: binary()
|
|
def disconnect, do: <<0xE0, 0x00>>
|
|
|
|
# ---------- Inbound parser ----------
|
|
|
|
@doc """
|
|
Parse the next packet from a buffer of received TCP bytes.
|
|
|
|
* `{:ok, packet, rest}` — packet decoded, `rest` is the
|
|
unconsumed tail (may contain more packets).
|
|
* `{:incomplete, buffer}` — short read; caller should append
|
|
the next TCP chunk and call again.
|
|
* `{:error, reason}` — protocol violation; caller should drop
|
|
the connection.
|
|
"""
|
|
@spec parse(binary()) :: {:ok, packet(), binary()} | {:incomplete, binary()} | {:error, term()}
|
|
def parse(<<>>), do: {:incomplete, <<>>}
|
|
|
|
def parse(<<type_flags::8, rest::binary>> = buffer) do
|
|
case decode_varint(rest) do
|
|
{:ok, len, after_len} when byte_size(after_len) >= len ->
|
|
<<body::binary-size(^len), tail::binary>> = after_len
|
|
decode_packet(type_flags, body, tail)
|
|
|
|
{:ok, _len, _} ->
|
|
{:incomplete, buffer}
|
|
|
|
:incomplete ->
|
|
{:incomplete, buffer}
|
|
|
|
{:error, _} = err ->
|
|
err
|
|
end
|
|
end
|
|
|
|
defp decode_packet(0x20, <<flags, return_code, _::binary>>, tail) do
|
|
{:ok, {:connack, return_code, (flags &&& 1) == 1}, tail}
|
|
end
|
|
|
|
# PUBLISH: top nibble is 3, low nibble is DUP/QoS/RETAIN. We only
|
|
# subscribe at QoS 0, so the broker won't send us QoS 1/2 — but
|
|
# we still tolerate the QoS 0 form (low nibble all zero or with
|
|
# RETAIN set). Anything else is rejected so a misconfigured
|
|
# subscription surfaces loudly instead of silently dropping bytes.
|
|
defp decode_packet(type_flags, body, tail) when (type_flags &&& 0xF0) == 0x30 do
|
|
qos = type_flags >>> 1 &&& 0b11
|
|
|
|
case qos do
|
|
0 ->
|
|
<<topic_len::16, topic::binary-size(topic_len), payload::binary>> = body
|
|
{:ok, {:publish, topic, payload}, tail}
|
|
|
|
_ ->
|
|
{:error, {:unsupported_qos, qos}}
|
|
end
|
|
end
|
|
|
|
defp decode_packet(0x90, <<packet_id::16, qos_results::binary>>, tail) do
|
|
{:ok, {:suback, packet_id, :erlang.binary_to_list(qos_results)}, tail}
|
|
end
|
|
|
|
defp decode_packet(0xD0, <<>>, tail), do: {:ok, :pingresp, tail}
|
|
|
|
defp decode_packet(type_flags, _body, _tail) do
|
|
{:error, {:unhandled_packet_type, type_flags}}
|
|
end
|
|
|
|
# ---------- Variable-byte integer ----------
|
|
|
|
@doc false
|
|
@spec encode_varint(non_neg_integer()) :: binary()
|
|
def encode_varint(n) do
|
|
n
|
|
|> encode_varint_io([])
|
|
|> IO.iodata_to_binary()
|
|
end
|
|
|
|
defp encode_varint_io(n, acc) when n <= 127, do: [acc, <<n>>]
|
|
|
|
defp encode_varint_io(n, acc) when n <= 268_435_455 do
|
|
encode_varint_io(n >>> 7, [acc, <<(n &&& 0x7F) ||| 0x80>>])
|
|
end
|
|
|
|
@doc false
|
|
@spec decode_varint(binary()) :: {:ok, non_neg_integer(), binary()} | :incomplete | {:error, term()}
|
|
def decode_varint(buffer), do: decode_varint(buffer, 0, 0)
|
|
|
|
defp decode_varint(_buffer, _shift, count) when count >= 4, do: {:error, :varint_too_long}
|
|
defp decode_varint(<<>>, _shift, _count), do: :incomplete
|
|
|
|
defp decode_varint(<<0::1, n::7, rest::binary>>, shift, _count) do
|
|
{:ok, n <<< shift, rest}
|
|
end
|
|
|
|
defp decode_varint(<<1::1, n::7, rest::binary>>, shift, count) do
|
|
case decode_varint(rest, shift + 7, count + 1) do
|
|
{:ok, tail, remaining} -> {:ok, n <<< shift ||| tail, remaining}
|
|
other -> other
|
|
end
|
|
end
|
|
|
|
defp fixed_header(byte1, body) do
|
|
<<byte1>> <> encode_varint(byte_size(body)) <> body
|
|
end
|
|
end
|