prop/docs/plans/2026-03-30-commercial-link-monitoring.md
Graham McIntire 10e8feb486
Add commercial link monitoring via SNMP polling
Poll UBNT AirFiber radios (AF11X + AF60-LR) every 5 minutes via
net-snmp CLI, storing signal metrics in commercial_samples. Fetches
ASOS weather alongside each cycle for propagation correlation.

Includes 7 seeded link definitions, Oban cron worker, and net-snmp
in the Docker image.
2026-03-30 13:02:59 -05:00

35 KiB

Commercial Link Monitoring Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Poll UBNT AirFiber radios via SNMP every 5 minutes, store signal metrics in PostgreSQL, and fetch ASOS weather alongside each cycle for propagation correlation.

Architecture: New Commercial context with Link and Sample schemas. An Oban cron worker (PollWorker) runs every 5 minutes, queries all enabled links, shells out to snmpget/snmpgetnext (net-snmp CLI) to poll each radio, inserts signal samples, then fetches ASOS weather for each unique weather station via the existing IemClient. Two radio types are supported: AF11X (single snmpget) and AF60-LR (snmpget + snmpgetnext for MAC-indexed station table).

Tech Stack: Elixir/Phoenix, Ecto, Oban, net-snmp CLI (snmpget/snmpgetnext), existing Weather.IemClient


Files:

  • Create: lib/microwaveprop/commercial/link.ex
  • Create: priv/repo/migrations/TIMESTAMP_create_commercial_links.exs
  • Create: test/microwaveprop/commercial/link_test.exs

Step 1: Write the failing test

# test/microwaveprop/commercial/link_test.exs
defmodule Microwaveprop.Commercial.LinkTest do
  use Microwaveprop.DataCase, async: true

  alias Microwaveprop.Commercial.Link

  @valid_attrs %{
    label: "verona-to-climax",
    host: "10.250.1.26",
    community: "public",
    radio_type: "af11x",
    endpoint_a: %{lat: 33.246171, lon: -96.426516, alt_m: 205.4},
    endpoint_b: %{lat: 33.187294, lon: -96.448128, alt_m: 191.0},
    weather_station: "KTKI",
    enabled: true
  }

  describe "changeset/2" do
    test "valid attributes" do
      changeset = Link.changeset(%Link{}, @valid_attrs)
      assert changeset.valid?
    end

    test "requires label, host, community, radio_type" do
      changeset = Link.changeset(%Link{}, %{})

      assert %{
               label: ["can't be blank"],
               host: ["can't be blank"],
               community: ["can't be blank"],
               radio_type: ["can't be blank"]
             } = errors_on(changeset)
    end

    test "validates radio_type inclusion" do
      changeset = Link.changeset(%Link{}, %{@valid_attrs | radio_type: "unknown"})
      assert %{radio_type: ["is invalid"]} = errors_on(changeset)
    end

    test "defaults enabled to true" do
      changeset = Link.changeset(%Link{}, @valid_attrs)
      assert Ecto.Changeset.get_field(changeset, :enabled) == true
    end

    test "persists to the database" do
      changeset = Link.changeset(%Link{}, @valid_attrs)
      assert {:ok, link} = Repo.insert(changeset)
      assert link.label == "verona-to-climax"
      assert link.host == "10.250.1.26"
      assert link.radio_type == "af11x"
      assert link.endpoint_a["lat"] == 33.246171
    end

    test "enforces unique label" do
      assert {:ok, _} = Repo.insert(Link.changeset(%Link{}, @valid_attrs))
      assert {:error, changeset} = Repo.insert(Link.changeset(%Link{}, @valid_attrs))
      assert %{label: ["has already been taken"]} = errors_on(changeset)
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/microwaveprop/commercial/link_test.exs Expected: Compilation error — Microwaveprop.Commercial.Link not found

Step 3: Generate the migration

Run: mix ecto.gen.migration create_commercial_links

Then fill in the migration:

defmodule Microwaveprop.Repo.Migrations.CreateCommercialLinks do
  use Ecto.Migration

  def change do
    create table(:commercial_links, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :label, :string, null: false
      add :host, :string, null: false
      add :community, :string, null: false
      add :radio_type, :string, null: false, default: "af11x"
      add :endpoint_a, :map
      add :endpoint_b, :map
      add :weather_station, :string
      add :enabled, :boolean, null: false, default: true

      timestamps(type: :utc_datetime)
    end

    create unique_index(:commercial_links, [:label])
    create index(:commercial_links, [:enabled])
  end
end

Step 4: Write the schema

# lib/microwaveprop/commercial/link.ex
defmodule Microwaveprop.Commercial.Link do
  @moduledoc false
  use Ecto.Schema

  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "commercial_links" do
    field :label, :string
    field :host, :string
    field :community, :string
    field :radio_type, :string, default: "af11x"
    field :endpoint_a, :map
    field :endpoint_b, :map
    field :weather_station, :string
    field :enabled, :boolean, default: true

    timestamps(type: :utc_datetime)
  end

  @required_fields ~w(label host community radio_type)a
  @optional_fields ~w(endpoint_a endpoint_b weather_station enabled)a

  def changeset(link, attrs) do
    link
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> validate_inclusion(:radio_type, ~w(af11x af60))
    |> unique_constraint(:label)
  end
end

Step 5: Run migration and tests

Run: mix ecto.migrate && mix test test/microwaveprop/commercial/link_test.exs Expected: All tests pass

Step 6: Commit

feat: add commercial link schema and migration

Files:

  • Create: priv/repo/migrations/TIMESTAMP_seed_commercial_links.exs
  • No test file — data migration verified by running mix ecto.migrate

Step 1: Write the seed migration

Run: mix ecto.gen.migration seed_commercial_links

defmodule Microwaveprop.Repo.Migrations.SeedCommercialLinks do
  use Ecto.Migration

  def up do
    now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_naive()

    links = [
      %{
        label: "verona-to-climax",
        host: "10.250.1.26",
        community: "kdyyJrT0Mm",
        radio_type: "af11x",
        endpoint_a: ~s|{"lat":33.246171,"lon":-96.426516,"alt_m":205.4}|,
        endpoint_b: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
        weather_station: "KTKI"
      },
      %{
        label: "new-hope-to-core",
        host: "10.250.1.58",
        community: "kdyyJrT0Mm",
        radio_type: "af11x",
        endpoint_a: ~s|{"lat":33.207279,"lon":-96.537762,"alt_m":216.4}|,
        endpoint_b: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
        weather_station: "KTKI"
      },
      %{
        label: "core-new-hope",
        host: "10.250.1.61",
        community: "kdyyJrT0Mm",
        radio_type: "af11x",
        endpoint_a: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
        endpoint_b: ~s|{"lat":33.207279,"lon":-96.537762,"alt_m":216.4}|,
        weather_station: "KTKI"
      },
      %{
        label: "982_380_60LR",
        host: "10.250.1.34",
        community: "kdyyJrT0Mm",
        radio_type: "af60",
        endpoint_a: ~s|{"lat":33.148921,"lon":-96.4958,"alt_m":186.5}|,
        endpoint_b: ~s|{"lat":33.174135,"lon":-96.491935,"alt_m":200.4}|,
        weather_station: "KTKI"
      },
      %{
        label: "380_982_60LR",
        host: "10.250.1.37",
        community: "kdyyJrT0Mm",
        radio_type: "af60",
        endpoint_a: ~s|{"lat":33.174135,"lon":-96.491935,"alt_m":200.4}|,
        endpoint_b: ~s|{"lat":33.148921,"lon":-96.4958,"alt_m":186.5}|,
        weather_station: "KTKI"
      },
      %{
        label: "core-to-climax",
        host: "10.250.1.93",
        community: "kdyyJrT0Mm",
        radio_type: "af11x",
        endpoint_a: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
        endpoint_b: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
        weather_station: "KTKI"
      },
      %{
        label: "climax-to-core",
        host: "10.250.1.90",
        community: "kdyyJrT0Mm",
        radio_type: "af11x",
        endpoint_a: ~s|{"lat":33.187294,"lon":-96.448128,"alt_m":191.0}|,
        endpoint_b: ~s|{"lat":33.174149,"lon":-96.491943,"alt_m":198.9}|,
        weather_station: "KTKI"
      }
    ]

    for link <- links do
      execute("""
      INSERT INTO commercial_links (id, label, host, community, radio_type, endpoint_a, endpoint_b, weather_station, enabled, inserted_at, updated_at)
      VALUES (gen_random_uuid(), '#{link.label}', '#{link.host}', '#{link.community}', '#{link.radio_type}', '#{link.endpoint_a}'::jsonb, '#{link.endpoint_b}'::jsonb, '#{link.weather_station}', true, '#{now}', '#{now}')
      ON CONFLICT (label) DO NOTHING
      """)
    end
  end

  def down do
    execute("DELETE FROM commercial_links")
  end
end

Step 2: Run migration

Run: mix ecto.migrate Expected: 7 rows inserted into commercial_links

Step 3: Verify in iex

Run: mix run -e "Microwaveprop.Repo.all(Microwaveprop.Commercial.Link) |> length() |> IO.inspect()" Expected: 7

Step 4: Commit

feat: seed commercial links from existing link definitions

Task 3: Sample Schema + Migration

Files:

  • Create: lib/microwaveprop/commercial/sample.ex
  • Create: priv/repo/migrations/TIMESTAMP_create_commercial_samples.exs
  • Create: test/microwaveprop/commercial/sample_test.exs

Step 1: Write the failing test

# test/microwaveprop/commercial/sample_test.exs
defmodule Microwaveprop.Commercial.SampleTest do
  use Microwaveprop.DataCase, async: true

  alias Microwaveprop.Commercial.Link
  alias Microwaveprop.Commercial.Sample

  defp create_link do
    %Link{}
    |> Link.changeset(%{
      label: "test-link",
      host: "10.0.0.1",
      community: "public",
      radio_type: "af11x"
    })
    |> Repo.insert!()
  end

  @sample_attrs %{
    sampled_at: ~U[2026-03-30 12:00:00.000000Z],
    rx_power_0: -55.0,
    tx_power: 20.0,
    link_state: 1,
    link_uptime: 86400,
    cur_tx_mod_rate: 6,
    rx_capacity: 450,
    tx_capacity: 450
  }

  describe "changeset/2" do
    test "valid attributes with link_id" do
      link = create_link()
      attrs = Map.put(@sample_attrs, :link_id, link.id)
      changeset = Sample.changeset(%Sample{}, attrs)
      assert changeset.valid?
    end

    test "requires link_id and sampled_at" do
      changeset = Sample.changeset(%Sample{}, %{})
      assert %{link_id: ["can't be blank"], sampled_at: ["can't be blank"]} = errors_on(changeset)
    end

    test "persists to the database" do
      link = create_link()
      attrs = Map.put(@sample_attrs, :link_id, link.id)

      assert {:ok, sample} = Repo.insert(Sample.changeset(%Sample{}, attrs))
      assert sample.rx_power_0 == -55.0
      assert sample.link_id == link.id
    end

    test "all signal fields are optional" do
      link = create_link()
      attrs = %{link_id: link.id, sampled_at: ~U[2026-03-30 12:00:00.000000Z]}
      changeset = Sample.changeset(%Sample{}, attrs)
      assert changeset.valid?
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/microwaveprop/commercial/sample_test.exs Expected: Compilation error — Microwaveprop.Commercial.Sample not found

Step 3: Generate migration

Run: mix ecto.gen.migration create_commercial_samples

defmodule Microwaveprop.Repo.Migrations.CreateCommercialSamples do
  use Ecto.Migration

  def change do
    create table(:commercial_samples, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :link_id, references(:commercial_links, type: :binary_id, on_delete: :delete_all), null: false
      add :sampled_at, :utc_datetime_usec, null: false

      # Local radio
      add :rx_power_0, :float
      add :rx_power_1, :float
      add :tx_power, :float
      add :tx_freq_mhz, :integer
      add :rx_freq_mhz, :integer
      add :radio_temp_0_c, :float
      add :radio_temp_1_c, :float

      # Modulation & capacity
      add :cur_tx_mod_rate, :integer
      add :remote_tx_mod_rate, :integer
      add :rx_capacity, :integer
      add :tx_capacity, :integer

      # Remote radio
      add :remote_tx_power, :float
      add :remote_rx_power_0, :float
      add :remote_rx_power_1, :float

      # Link status
      add :link_state, :integer
      add :link_uptime, :integer
      add :link_distance_m, :integer

      timestamps(type: :utc_datetime)
    end

    create index(:commercial_samples, [:link_id, :sampled_at])
    create index(:commercial_samples, [:sampled_at])
  end
end

Step 4: Write the schema

# lib/microwaveprop/commercial/sample.ex
defmodule Microwaveprop.Commercial.Sample do
  @moduledoc false
  use Ecto.Schema

  import Ecto.Changeset

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id

  schema "commercial_samples" do
    belongs_to :link, Microwaveprop.Commercial.Link
    field :sampled_at, :utc_datetime_usec

    # Local radio
    field :rx_power_0, :float
    field :rx_power_1, :float
    field :tx_power, :float
    field :tx_freq_mhz, :integer
    field :rx_freq_mhz, :integer
    field :radio_temp_0_c, :float
    field :radio_temp_1_c, :float

    # Modulation & capacity
    field :cur_tx_mod_rate, :integer
    field :remote_tx_mod_rate, :integer
    field :rx_capacity, :integer
    field :tx_capacity, :integer

    # Remote radio
    field :remote_tx_power, :float
    field :remote_rx_power_0, :float
    field :remote_rx_power_1, :float

    # Link status
    field :link_state, :integer
    field :link_uptime, :integer
    field :link_distance_m, :integer

    timestamps(type: :utc_datetime)
  end

  @required_fields ~w(link_id sampled_at)a
  @optional_fields ~w(rx_power_0 rx_power_1 tx_power tx_freq_mhz rx_freq_mhz
    radio_temp_0_c radio_temp_1_c cur_tx_mod_rate remote_tx_mod_rate
    rx_capacity tx_capacity remote_tx_power remote_rx_power_0 remote_rx_power_1
    link_state link_uptime link_distance_m)a

  def changeset(sample, attrs) do
    sample
    |> cast(attrs, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> foreign_key_constraint(:link_id)
  end
end

Step 5: Run migration and tests

Run: mix ecto.migrate && mix test test/microwaveprop/commercial/sample_test.exs Expected: All tests pass

Step 6: Commit

feat: add commercial sample schema and migration

Task 4: Commercial Context Module

Files:

  • Create: lib/microwaveprop/commercial.ex
  • Create: test/microwaveprop/commercial_test.exs

Step 1: Write the failing test

# test/microwaveprop/commercial_test.exs
defmodule Microwaveprop.CommercialTest do
  use Microwaveprop.DataCase, async: true

  alias Microwaveprop.Commercial

  @link_attrs %{
    label: "test-link",
    host: "10.0.0.1",
    community: "public",
    radio_type: "af11x",
    weather_station: "KTKI",
    enabled: true
  }

  describe "enabled_links/0" do
    test "returns only enabled links" do
      {:ok, _enabled} = Commercial.create_link(@link_attrs)
      {:ok, _disabled} = Commercial.create_link(%{@link_attrs | label: "disabled-link", enabled: false})

      links = Commercial.enabled_links()
      assert length(links) == 1
      assert hd(links).label == "test-link"
    end

    test "returns empty list when no enabled links" do
      assert Commercial.enabled_links() == []
    end
  end

  describe "create_link/1" do
    test "creates a link with valid attrs" do
      assert {:ok, link} = Commercial.create_link(@link_attrs)
      assert link.label == "test-link"
      assert link.enabled == true
    end

    test "returns error for invalid attrs" do
      assert {:error, _changeset} = Commercial.create_link(%{})
    end
  end

  describe "create_sample/1" do
    test "creates a sample for a link" do
      {:ok, link} = Commercial.create_link(@link_attrs)

      attrs = %{
        link_id: link.id,
        sampled_at: DateTime.utc_now(),
        rx_power_0: -55.0,
        tx_power: 20.0,
        link_state: 1
      }

      assert {:ok, sample} = Commercial.create_sample(attrs)
      assert sample.rx_power_0 == -55.0
      assert sample.link_id == link.id
    end
  end

  describe "weather_stations/0" do
    test "returns unique weather stations from enabled links" do
      {:ok, _} = Commercial.create_link(@link_attrs)
      {:ok, _} = Commercial.create_link(%{@link_attrs | label: "link-2", host: "10.0.0.2"})
      {:ok, _} = Commercial.create_link(%{@link_attrs | label: "link-3", host: "10.0.0.3", weather_station: "KDFW"})

      stations = Commercial.weather_stations()
      assert Enum.sort(stations) == ["KDFW", "KTKI"]
    end

    test "excludes disabled links" do
      {:ok, _} = Commercial.create_link(%{@link_attrs | enabled: false})

      assert Commercial.weather_stations() == []
    end

    test "excludes nil weather stations" do
      {:ok, _} = Commercial.create_link(%{@link_attrs | weather_station: nil})

      assert Commercial.weather_stations() == []
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/microwaveprop/commercial_test.exs Expected: Compilation error — Microwaveprop.Commercial not found

Step 3: Write the context module

# lib/microwaveprop/commercial.ex
defmodule Microwaveprop.Commercial do
  @moduledoc false

  import Ecto.Query

  alias Microwaveprop.Commercial.Link
  alias Microwaveprop.Commercial.Sample
  alias Microwaveprop.Repo

  def enabled_links do
    Link
    |> where([l], l.enabled == true)
    |> Repo.all()
  end

  def create_link(attrs) do
    %Link{}
    |> Link.changeset(attrs)
    |> Repo.insert()
  end

  def create_sample(attrs) do
    %Sample{}
    |> Sample.changeset(attrs)
    |> Repo.insert()
  end

  def weather_stations do
    Link
    |> where([l], l.enabled == true and not is_nil(l.weather_station))
    |> select([l], l.weather_station)
    |> distinct(true)
    |> Repo.all()
  end
end

Step 4: Run tests

Run: mix test test/microwaveprop/commercial_test.exs Expected: All tests pass

Step 5: Commit

feat: add commercial context with link and sample CRUD

Task 5: SNMP Client

Files:

  • Create: lib/microwaveprop/commercial/snmp_client.ex
  • Create: test/microwaveprop/commercial/snmp_client_test.exs

Step 1: Write the failing test

The SNMP client shells out to snmpget/snmpgetnext. Tests will mock System.cmd/3 by making the module accept a command runner function (dependency injection via application config in test).

# test/microwaveprop/commercial/snmp_client_test.exs
defmodule Microwaveprop.Commercial.SnmpClientTest do
  use ExUnit.Case, async: true

  alias Microwaveprop.Commercial.SnmpClient

  @af11x_output """
  iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55
  iso.3.6.1.4.1.41112.1.3.2.1.14.1 = INTEGER: -56
  iso.3.6.1.4.1.41112.1.3.1.1.9.1 = INTEGER: 20
  iso.3.6.1.4.1.41112.1.3.2.1.2.1 = INTEGER: 6
  iso.3.6.1.4.1.41112.1.3.2.1.18.1 = INTEGER: 6
  iso.3.6.1.4.1.41112.1.3.2.1.5.1 = INTEGER: 450
  iso.3.6.1.4.1.41112.1.3.2.1.6.1 = INTEGER: 450
  iso.3.6.1.4.1.41112.1.3.2.1.17.1 = INTEGER: 20
  iso.3.6.1.4.1.41112.1.3.2.1.19.1 = INTEGER: -54
  iso.3.6.1.4.1.41112.1.3.2.1.22.1 = INTEGER: -57
  iso.3.6.1.4.1.41112.1.3.2.1.26.1 = INTEGER: 1
  iso.3.6.1.4.1.41112.1.3.2.1.44.1 = INTEGER: 86400
  iso.3.6.1.4.1.41112.1.3.2.1.8.1 = INTEGER: 45
  iso.3.6.1.4.1.41112.1.3.2.1.10.1 = INTEGER: 47
  iso.3.6.1.4.1.41112.1.3.1.1.5.1 = INTEGER: 11245
  iso.3.6.1.4.1.41112.1.3.1.1.6.1 = INTEGER: 10720
  iso.3.6.1.4.1.41112.1.3.2.1.4.1 = INTEGER: 7200
  """

  describe "parse_snmpget_output/2" do
    test "parses AF11X snmpget output into signal map" do
      result = SnmpClient.parse_snmpget_output(@af11x_output, :af11x)

      assert result.rx_power_0 == -55
      assert result.rx_power_1 == -56
      assert result.tx_power == 20
      assert result.cur_tx_mod_rate == 6
      assert result.remote_tx_mod_rate == 6
      assert result.rx_capacity == 450
      assert result.tx_capacity == 450
      assert result.remote_tx_power == 20
      assert result.remote_rx_power_0 == -54
      assert result.remote_rx_power_1 == -57
      assert result.link_state == 1
      assert result.link_uptime == 86400
      assert result.radio_temp_0_c == 45
      assert result.radio_temp_1_c == 47
      assert result.tx_freq_mhz == 11245
      assert result.rx_freq_mhz == 10720
      assert result.link_distance_m == 7200
    end

    test "returns empty map for empty output" do
      assert SnmpClient.parse_snmpget_output("", :af11x) == %{}
    end

    test "skips lines with No Such Instance" do
      output = """
      iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -55
      iso.3.6.1.4.1.41112.1.3.2.1.14.1 = No Such Instance currently exists at this OID
      """

      result = SnmpClient.parse_snmpget_output(output, :af11x)
      assert result.rx_power_0 == -55
      refute Map.has_key?(result, :rx_power_1)
    end
  end

  @af60_static_output """
  iso.3.6.1.4.1.41112.1.11.1.2.5.1 = INTEGER: 52
  iso.3.6.1.4.1.41112.1.11.1.2.6.1 = INTEGER: 1
  iso.3.6.1.4.1.41112.1.11.1.1.2.1 = INTEGER: 60480
  """

  @af60_station_output """
  iso.3.6.1.4.1.41112.1.11.1.3.1.3.170.211.109.42.26.137 = INTEGER: -58
  iso.3.6.1.4.1.41112.1.11.1.3.1.4.170.211.109.42.26.137 = INTEGER: 18
  iso.3.6.1.4.1.41112.1.11.1.3.1.5.170.211.109.42.26.137 = INTEGER: 9
  iso.3.6.1.4.1.41112.1.11.1.3.1.6.170.211.109.42.26.137 = INTEGER: 8
  iso.3.6.1.4.1.41112.1.11.1.3.1.7.170.211.109.42.26.137 = INTEGER: 900000
  iso.3.6.1.4.1.41112.1.11.1.3.1.8.170.211.109.42.26.137 = INTEGER: 850000
  iso.3.6.1.4.1.41112.1.11.1.3.1.15.170.211.109.42.26.137 = INTEGER: 2800
  iso.3.6.1.4.1.41112.1.11.1.3.1.17.170.211.109.42.26.137 = INTEGER: 8640000
  iso.3.6.1.4.1.41112.1.11.1.3.1.18.170.211.109.42.26.137 = INTEGER: -57
  iso.3.6.1.4.1.41112.1.11.1.3.1.19.170.211.109.42.26.137 = INTEGER: 19
  """

  describe "parse_af60_output/2" do
    test "parses AF60 static + station output" do
      result = SnmpClient.parse_af60_output(@af60_static_output, @af60_station_output)

      assert result.radio_temp_0_c == 52
      assert result.link_state == 1
      assert result.tx_freq_mhz == 60480
      assert result.rx_power_0 == -58
      assert result.tx_power == 18
      assert result.cur_tx_mod_rate == 9
      assert result.remote_tx_mod_rate == 8
      assert result.tx_capacity == 900
      assert result.rx_capacity == 850
      assert result.link_distance_m == 2800
      assert result.link_uptime == 86400
      assert result.remote_rx_power_0 == -57
      assert result.remote_tx_power == 19
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/microwaveprop/commercial/snmp_client_test.exs Expected: Compilation error — Microwaveprop.Commercial.SnmpClient not found

Step 3: Write the SNMP client

# lib/microwaveprop/commercial/snmp_client.ex
defmodule Microwaveprop.Commercial.SnmpClient do
  @moduledoc false

  require Logger

  # UBNT-AirFIBER-MIB OIDs (base 1.3.6.1.4.1.41112.1.3)
  @af_base "1.3.6.1.4.1.41112.1.3"
  @af11x_oids %{
    "#{@af_base}.2.1.11.1" => :rx_power_0,
    "#{@af_base}.2.1.14.1" => :rx_power_1,
    "#{@af_base}.1.1.9.1" => :tx_power,
    "#{@af_base}.2.1.2.1" => :cur_tx_mod_rate,
    "#{@af_base}.2.1.18.1" => :remote_tx_mod_rate,
    "#{@af_base}.2.1.5.1" => :rx_capacity,
    "#{@af_base}.2.1.6.1" => :tx_capacity,
    "#{@af_base}.2.1.17.1" => :remote_tx_power,
    "#{@af_base}.2.1.19.1" => :remote_rx_power_0,
    "#{@af_base}.2.1.22.1" => :remote_rx_power_1,
    "#{@af_base}.2.1.26.1" => :link_state,
    "#{@af_base}.2.1.44.1" => :link_uptime,
    "#{@af_base}.2.1.8.1" => :radio_temp_0_c,
    "#{@af_base}.2.1.10.1" => :radio_temp_1_c,
    "#{@af_base}.1.1.5.1" => :tx_freq_mhz,
    "#{@af_base}.1.1.6.1" => :rx_freq_mhz,
    "#{@af_base}.2.1.4.1" => :link_distance_m
  }

  # AF60-LR static OIDs
  @af60_base "1.3.6.1.4.1.41112.1.11.1"
  @af60_static_oids %{
    "#{@af60_base}.2.5.1" => :radio_temp_0_c,
    "#{@af60_base}.2.6.1" => :link_state,
    "#{@af60_base}.1.2.1" => :tx_freq_mhz
  }

  # AF60 station table column -> field name + unit conversion
  @af60_station_cols %{
    3 => {:rx_power_0, :raw},
    4 => {:tx_power, :raw},
    5 => {:cur_tx_mod_rate, :raw},
    6 => {:remote_tx_mod_rate, :raw},
    7 => {:tx_capacity, :kbps_to_mbps},
    8 => {:rx_capacity, :kbps_to_mbps},
    15 => {:link_distance_m, :raw},
    17 => {:link_uptime, :centiseconds_to_seconds},
    18 => {:remote_rx_power_0, :raw},
    19 => {:remote_tx_power, :raw}
  }

  @snmp_timeout 5_000

  def poll(host, community, "af11x"), do: poll_af11x(host, community)
  def poll(host, community, "af60"), do: poll_af60(host, community)

  def poll_af11x(host, community) do
    oids = Map.keys(@af11x_oids)
    args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | oids]

    case run_cmd("snmpget", args) do
      {output, 0} ->
        result = parse_snmpget_output(output, :af11x)
        if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}

      {output, _exit_code} ->
        Logger.warning("snmpget failed for #{host}: #{String.trim(output)}")
        {:error, :snmp_failed}
    end
  end

  def poll_af60(host, community) do
    static_oids = Map.keys(@af60_static_oids)
    static_args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | static_oids]

    station_oids = Enum.map(Map.keys(@af60_station_cols), &"#{@af60_base}.3.1.#{&1}")
    station_args = ["-v2c", "-c", community, "-t", "5", "-r", "1", host | station_oids]

    with {static_out, 0} <- run_cmd("snmpget", static_args),
         {station_out, 0} <- run_cmd("snmpgetnext", station_args) do
      result = parse_af60_output(static_out, station_out)
      if map_size(result) > 0, do: {:ok, result}, else: {:error, :empty_response}
    else
      {output, _} ->
        Logger.warning("snmp failed for AF60 #{host}: #{String.trim(output)}")
        {:error, :snmp_failed}
    end
  end

  def parse_snmpget_output(output, :af11x) do
    output
    |> String.split("\n", trim: true)
    |> Enum.reduce(%{}, fn line, acc ->
      case parse_snmp_line(line) do
        {oid, value} ->
          case find_af11x_field(oid) do
            nil -> acc
            field -> Map.put(acc, field, value)
          end

        nil ->
          acc
      end
    end)
  end

  def parse_af60_output(static_output, station_output) do
    static =
      static_output
      |> String.split("\n", trim: true)
      |> Enum.reduce(%{}, fn line, acc ->
        case parse_snmp_line(line) do
          {oid, value} ->
            case find_af60_static_field(oid) do
              nil -> acc
              field -> Map.put(acc, field, value)
            end

          nil ->
            acc
        end
      end)

    station =
      station_output
      |> String.split("\n", trim: true)
      |> Enum.reduce(%{}, fn line, acc ->
        case parse_snmp_line(line) do
          {oid, value} ->
            case find_af60_station_field(oid) do
              nil -> acc
              {field, conversion} -> Map.put(acc, field, convert_value(value, conversion))
            end

          nil ->
            acc
        end
      end)

    Map.merge(static, station)
  end

  # --- Private ---

  defp run_cmd(cmd, args) do
    System.cmd(cmd, args, stderr_to_stdout: true, timeout: @snmp_timeout)
  rescue
    ErlangError -> {"command not found: #{cmd}", 127}
  end

  defp parse_snmp_line(line) do
    # Format: "iso.3.6.1.4... = INTEGER: -55"  or  "...= No Such Instance..."
    case String.split(line, " = ", parts: 2) do
      [oid_part, value_part] ->
        if String.contains?(value_part, "No Such") do
          nil
        else
          oid = oid_part |> String.trim() |> normalize_oid()
          value = parse_snmp_value(value_part)
          if value, do: {oid, value}, else: nil
        end

      _ ->
        nil
    end
  end

  defp normalize_oid(oid) do
    oid
    |> String.replace(~r/^iso\./, "1.")
    |> String.replace(~r/^\./, "")
  end

  defp parse_snmp_value(value_str) do
    # "INTEGER: -55" or "Gauge32: 100" or "STRING: \"foo\""
    case Regex.run(~r/:\s*(.+)$/, String.trim(value_str)) do
      [_, raw] ->
        raw = String.trim(raw, "\"")

        case Integer.parse(raw) do
          {val, _} -> val
          :error -> nil
        end

      nil ->
        nil
    end
  end

  defp find_af11x_field(oid) do
    Enum.find_value(@af11x_oids, fn {known_oid, field} ->
      if oid == known_oid or String.starts_with?(oid, known_oid), do: field
    end)
  end

  defp find_af60_static_field(oid) do
    Enum.find_value(@af60_static_oids, fn {known_oid, field} ->
      if oid == known_oid or String.starts_with?(oid, known_oid), do: field
    end)
  end

  defp find_af60_station_field(oid) do
    # Station table OIDs: {base}.3.1.{col}.{mac_bytes}
    Enum.find_value(@af60_station_cols, fn {col, field_and_conv} ->
      prefix = "#{@af60_base}.3.1.#{col}."
      if String.starts_with?(oid, prefix), do: field_and_conv
    end)
  end

  defp convert_value(value, :raw), do: value
  defp convert_value(value, :kbps_to_mbps), do: div(value, 1000)
  defp convert_value(value, :centiseconds_to_seconds), do: div(value, 100)
end

Step 4: Run tests

Run: mix test test/microwaveprop/commercial/snmp_client_test.exs Expected: All tests pass

Step 5: Commit

feat: add SNMP client for AF11X and AF60-LR radio polling

Task 6: Poll Worker

Files:

  • Create: lib/microwaveprop/commercial/poll_worker.ex
  • Create: test/microwaveprop/commercial/poll_worker_test.exs
  • Modify: config/config.exs (add queue + cron)
  • Modify: config/test.exs (add snmp test config)

Step 1: Write the failing test

# test/microwaveprop/commercial/poll_worker_test.exs
defmodule Microwaveprop.Commercial.PollWorkerTest do
  use Microwaveprop.DataCase, async: false

  alias Microwaveprop.Commercial
  alias Microwaveprop.Commercial.PollWorker
  alias Microwaveprop.Commercial.Sample

  @link_attrs %{
    label: "test-link",
    host: "10.0.0.1",
    community: "public",
    radio_type: "af11x",
    weather_station: "KTKI",
    enabled: true
  }

  describe "perform/1" do
    test "creates samples for enabled links when SNMP succeeds" do
      {:ok, link} = Commercial.create_link(@link_attrs)

      # Stub the SNMP client to return success
      Req.Test.stub(Microwaveprop.Commercial.SnmpClient, fn _conn ->
        Req.Test.json(%{}, %{})
      end)

      # Use a test-friendly approach: override poll function
      snmp_result = %{
        rx_power_0: -55,
        tx_power: 20,
        link_state: 1,
        link_uptime: 86400
      }

      PollWorker.poll_and_record([link], fn _host, _community, _type -> {:ok, snmp_result} end)

      samples = Repo.all(Sample)
      assert length(samples) == 1
      assert hd(samples).rx_power_0 == -55.0
      assert hd(samples).link_id == link.id
    end

    test "skips links when SNMP fails" do
      {:ok, _link} = Commercial.create_link(@link_attrs)

      PollWorker.poll_and_record(
        Commercial.enabled_links(),
        fn _host, _community, _type -> {:error, :snmp_failed} end
      )

      assert Repo.all(Sample) == []
    end

    test "continues polling remaining links after one failure" do
      {:ok, _link1} = Commercial.create_link(@link_attrs)
      {:ok, _link2} = Commercial.create_link(%{@link_attrs | label: "link-2", host: "10.0.0.2"})

      call_count = :counters.new(1, [:atomics])

      poll_fn = fn _host, _community, _type ->
        n = :counters.get(call_count, 1)
        :counters.add(call_count, 1, 1)

        if n == 0 do
          {:error, :snmp_failed}
        else
          {:ok, %{rx_power_0: -60, link_state: 1}}
        end
      end

      PollWorker.poll_and_record(Commercial.enabled_links(), poll_fn)

      assert length(Repo.all(Sample)) == 1
    end
  end
end

Step 2: Run test to verify it fails

Run: mix test test/microwaveprop/commercial/poll_worker_test.exs Expected: Compilation error — Microwaveprop.Commercial.PollWorker not found

Step 3: Write the poll worker

# lib/microwaveprop/commercial/poll_worker.ex
defmodule Microwaveprop.Commercial.PollWorker do
  @moduledoc false
  use Oban.Worker, queue: :commercial, max_attempts: 1

  require Logger

  alias Microwaveprop.Commercial
  alias Microwaveprop.Commercial.SnmpClient
  alias Microwaveprop.Weather
  alias Microwaveprop.Weather.IemClient

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    links = Commercial.enabled_links()

    poll_and_record(links, &SnmpClient.poll/3)
    fetch_weather(links)

    :ok
  end

  def poll_and_record(links, poll_fn) do
    now = DateTime.utc_now()

    Enum.each(links, fn link ->
      case poll_fn.(link.host, link.community, link.radio_type) do
        {:ok, data} ->
          attrs =
            data
            |> Map.put(:link_id, link.id)
            |> Map.put(:sampled_at, now)

          case Commercial.create_sample(attrs) do
            {:ok, _sample} ->
              Logger.info("Recorded sample for #{link.label}")

            {:error, changeset} ->
              Logger.warning("Failed to save sample for #{link.label}: #{inspect(changeset.errors)}")
          end

        {:error, reason} ->
          Logger.warning("SNMP poll failed for #{link.label} (#{link.host}): #{inspect(reason)}")
      end
    end)
  end

  defp fetch_weather(links) do
    stations =
      links
      |> Enum.map(& &1.weather_station)
      |> Enum.reject(&is_nil/1)
      |> Enum.uniq()

    now = DateTime.utc_now()
    start_dt = DateTime.add(now, -3600, :second)

    Enum.each(stations, fn station_code ->
      {:ok, station} =
        Weather.find_or_create_station(%{
          station_code: station_code,
          station_type: "asos",
          name: station_code,
          lat: 0.0,
          lon: 0.0
        })

      case IemClient.fetch_asos(station_code, start_dt, now) do
        {:ok, rows} ->
          Enum.each(rows, fn row ->
            if row.observed_at, do: Weather.upsert_surface_observation(station, row)
          end)

          Logger.info("Fetched #{length(rows)} ASOS observations for #{station_code}")

        {:error, reason} ->
          Logger.warning("ASOS fetch failed for #{station_code}: #{inspect(reason)}")
      end
    end)
  end
end

Step 4: Update config/config.exs — add the commercial queue and cron entry

In config/config.exs, update the Oban config:

Change:

  queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4],

To:

  queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4, commercial: 2],

And add to the crontab:

       {"*/5 * * * *", Microwaveprop.Commercial.PollWorker}

Step 5: Run tests

Run: mix test test/microwaveprop/commercial/poll_worker_test.exs Expected: All tests pass

Step 6: Commit

feat: add commercial poll worker with 5-minute cron schedule

Task 7: Dockerfile — Add net-snmp

Files:

  • Modify: Dockerfile

Step 1: Add snmp-utils to the runner image

In the FROM ${RUNNER_IMAGE} AS final section, add snmp to the apt-get install line:

Change:

RUN apt-get update \
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates \
  && rm -rf /var/lib/apt/lists/*

To:

RUN apt-get update \
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates snmp \
  && rm -rf /var/lib/apt/lists/*

The snmp package provides snmpget and snmpgetnext on Debian.

Step 2: Commit

feat: add net-snmp to Docker image for commercial link polling

Task 8: Integration — Run precommit

Step 1: Format

Run: mix format Note: Styler will rewrite code. Let it.

Step 2: Run precommit

Run: mix precommit Expected: Compiles with no warnings, format clean, all tests pass

Step 3: Fix any issues

If Styler rewrites anything (e.g., with to case, pipe adjustments), accept the changes. If there are warnings-as-errors, fix them.

Step 4: Final commit if needed

chore: fix formatting and warnings from precommit