# 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` --- ## Task 1: Link Schema + Migration **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** ```elixir # 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: ```elixir 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** ```elixir # 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 ``` --- ## Task 2: Seed Migration — Import Links from links.yaml **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` ```elixir 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** ```elixir # 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` ```elixir 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** ```elixir # 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** ```elixir # 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** ```elixir # 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). ```elixir # 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** ```elixir # 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** ```elixir # 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** ```elixir # 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: ```elixir queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4], ``` To: ```elixir queues: [solar: 1, weather: 3, enqueue: 1, hrrr: 20, terrain: 4, commercial: 2], ``` And add to the crontab: ```elixir {"*/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: ```dockerfile 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: ```dockerfile 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 ```