diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index 22d648d7..2a836704 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -1,3 +1,31 @@
+2026-05-09
+feat(insights): AP frequency-change recommendation rule
+ Schema: new wireless_neighbor_scans table (migration 20260509214247)
+ storing observed neighboring APs per device with bssid, ssid, channel,
+ frequency_mhz, channel_width_mhz, rssi_dbm, is_own_fleet, seen_at.
+ Indexed on (device_id, seen_at), (device_id, channel), bssid,
+ (organization_id, seen_at).
+ Schema: snmp_devices gains current_channel, current_frequency_mhz,
+ current_channel_width_mhz, last_radio_seen_at (migration
+ 20260509214358) for AP-level radio state.
+ New rule Towerops.Recommendations.Rules.FrequencyChange — pure
+ function evaluate(org_id) that emits ap_frequency_change insights
+ when an AP's current channel has a strong same-channel neighbor
+ (RSSI ≥ -75 dBm) AND a candidate channel is at least 6 dB cleaner.
+ Score = Σ 10^((rssi+95)/10) over scans in last 24h. Critical when
+ current score ≥ 100. Picks recommended channel from per-band
+ candidate lists (5GHz UNII or 2.4GHz 1/6/11). Top offenders sorted
+ by RSSI desc, capped at 5.
+ New cron Towerops.Workers.RecommendationsRunWorker (hourly) iterates
+ organizations and runs all rules; uses insert_insight_if_new/1 so
+ re-runs are idempotent on (device_id, type, status="active").
+ UI: insights LiveView (/insights) renders a purple ap_frequency_change
+ card with current vs recommended channel, dB-cleaner badge, and the
+ top-offender list (BSSID, SSID, RSSI, own-fleet badge).
+ Insight.@valid_types extended with ap_frequency_change.
+ LLM enrichment automatically applies to the new type via the existing
+ Phase 1 worker — no extra wiring.
+
2026-05-09
feat(insights): LLM-powered insight enrichment
Schema: added llm_summary, recommended_action, llm_model, llm_enriched_at
diff --git a/config/runtime.exs b/config/runtime.exs
index 71545722..51f32212 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -260,6 +260,8 @@ if config_env() == :prod do
{"*/5 * * * *", Towerops.Workers.WirelessInsightWorker},
# Enrich active insights with LLM-generated summaries every 5 minutes
{"*/5 * * * *", Towerops.Workers.InsightLlmEnrichmentWorker},
+ # Run multi-source recommendation rules hourly
+ {"0 * * * *", Towerops.Workers.RecommendationsRunWorker},
# Gaiia reconciliation insights nightly at 4:30 AM
{"30 4 * * *", Towerops.Workers.GaiiaInsightWorker},
# Backhaul capacity utilization insights every 15 minutes
diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex
index bdf04d98..3c860b27 100644
--- a/lib/towerops/preseem/insight.ex
+++ b/lib/towerops/preseem/insight.ex
@@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
- @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap)
+ @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)
diff --git a/lib/towerops/recommendations/rules/frequency_change.ex b/lib/towerops/recommendations/rules/frequency_change.ex
new file mode 100644
index 00000000..a80bc407
--- /dev/null
+++ b/lib/towerops/recommendations/rules/frequency_change.ex
@@ -0,0 +1,225 @@
+defmodule Towerops.Recommendations.Rules.FrequencyChange do
+ @moduledoc """
+ Recommendation rule that detects access points whose current channel
+ has strong same-channel interference and a measurably better
+ alternative within the same band.
+
+ Inputs:
+ - `snmp_devices` rows with a non-nil `current_channel`
+ - `wireless_neighbor_scans` rows from the last 24h
+
+ Output:
+ - List of `%{}` attribute maps suitable for
+ `Towerops.Preseem.Insights.insert_insight_if_new/1`. Each insight has
+ type `"ap_frequency_change"` and metadata containing the current
+ channel, recommended channel, interference scores, and the top
+ offending neighbors observed on the current channel.
+
+ Scoring:
+ score(channel) = Σ 10^((rssi + 95) / 10)
+ over neighbor scans observed on `channel` in the last 24h. Higher
+ score = more interference. We recommend the candidate channel with
+ the lowest score, provided the improvement is at least 6 dB.
+
+ This rule is deterministic and side-effect free — the worker is
+ responsible for upserting the resulting insights.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Repo
+ alias Towerops.Snmp.Device, as: SnmpDevice
+ alias Towerops.Snmp.WirelessNeighborScan
+
+ @lookback_seconds 24 * 3600
+
+ # Channel candidate lists per band. Conservative defaults — operators
+ # can refine per-deployment later.
+ @candidates_5ghz [36, 40, 44, 48, 149, 153, 157, 161, 165]
+ @candidates_24ghz [1, 6, 11]
+
+ # An interference contributor is "strong" enough to consider for an
+ # offender list when its RSSI is at or above this threshold.
+ @strong_offender_threshold_dbm -75
+
+ # Don't recommend a switch unless the alternative is at least this much
+ # quieter than the current channel (in dB-equivalent score units).
+ @min_improvement_db 6.0
+
+ # Score above this puts the recommendation at "critical" urgency.
+ @critical_score 100.0
+
+ @max_offenders 5
+
+ @type insight_attrs :: %{
+ required(:organization_id) => Ecto.UUID.t(),
+ required(:device_id) => Ecto.UUID.t(),
+ required(:type) => String.t(),
+ required(:urgency) => String.t(),
+ required(:channel) => String.t(),
+ required(:source) => String.t(),
+ required(:title) => String.t(),
+ required(:metadata) => map()
+ }
+
+ @doc """
+ Evaluate the rule for an organization and return a list of insight
+ attribute maps. Empty list when nothing should be recommended.
+ """
+ @spec evaluate(Ecto.UUID.t()) :: [insight_attrs()]
+ def evaluate(organization_id) when is_binary(organization_id) do
+ cutoff =
+ DateTime.utc_now()
+ |> DateTime.add(-@lookback_seconds, :second)
+ |> DateTime.truncate(:second)
+
+ organization_id
+ |> load_aps()
+ |> Enum.map(&evaluate_ap(&1, organization_id, cutoff))
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp load_aps(organization_id) do
+ Repo.all(
+ from(s in SnmpDevice,
+ join: d in assoc(s, :device),
+ where: d.organization_id == ^organization_id and not is_nil(s.current_channel),
+ select: {d, s}
+ )
+ )
+ end
+
+ defp evaluate_ap({device, snmp}, organization_id, cutoff) do
+ scans = neighbor_scans_for(device.id, cutoff)
+
+ if has_strong_offender?(scans, snmp.current_channel) do
+ build_insight(device, snmp, scans, organization_id)
+ end
+ end
+
+ defp neighbor_scans_for(device_id, cutoff) do
+ Repo.all(
+ from(s in WirelessNeighborScan,
+ where: s.device_id == ^device_id and s.seen_at >= ^cutoff,
+ order_by: [desc: s.rssi_dbm]
+ )
+ )
+ end
+
+ defp has_strong_offender?(scans, current_channel) do
+ Enum.any?(scans, fn s ->
+ s.channel == current_channel and not is_nil(s.rssi_dbm) and
+ s.rssi_dbm >= @strong_offender_threshold_dbm
+ end)
+ end
+
+ defp build_insight(device, snmp, scans, organization_id) do
+ candidates = candidates_for(snmp.current_frequency_mhz, snmp.current_channel)
+ scored = score_channels(scans, candidates)
+
+ current_score = Map.get(scored, snmp.current_channel, 0.0)
+
+ {recommended_channel, recommended_score} =
+ Enum.min_by(scored, fn {_ch, score} -> score end, fn -> {nil, nil} end)
+
+ cond do
+ is_nil(recommended_channel) ->
+ nil
+
+ recommended_channel == snmp.current_channel ->
+ nil
+
+ not improvement_worth_it?(current_score, recommended_score) ->
+ nil
+
+ true ->
+ offenders = top_offenders(scans, snmp.current_channel)
+
+ %{
+ organization_id: organization_id,
+ device_id: device.id,
+ type: "ap_frequency_change",
+ urgency: urgency_for(current_score),
+ channel: "proactive",
+ source: "system",
+ title:
+ "AP #{device.name || snmp.sys_name || "(unnamed)"} should change channel: " <>
+ "#{snmp.current_channel} → #{recommended_channel}",
+ metadata: %{
+ "current_channel" => snmp.current_channel,
+ "current_frequency_mhz" => snmp.current_frequency_mhz,
+ "channel_width_mhz" => snmp.current_channel_width_mhz,
+ "recommended_channel" => recommended_channel,
+ "current_score" => current_score,
+ "recommended_score" => recommended_score,
+ "improvement_db" => score_to_db(current_score) - score_to_db(recommended_score),
+ "top_offenders" => offenders,
+ "lookback_hours" => 24
+ }
+ }
+ end
+ end
+
+ defp candidates_for(frequency_mhz, current_channel) when is_integer(frequency_mhz) do
+ cond do
+ frequency_mhz >= 5000 -> @candidates_5ghz
+ frequency_mhz >= 2400 and frequency_mhz < 2500 -> @candidates_24ghz
+ true -> [current_channel]
+ end
+ end
+
+ defp candidates_for(_freq, current_channel), do: [current_channel]
+
+ defp score_channels(scans, candidates) do
+ by_channel =
+ scans
+ |> Enum.filter(&(&1.channel != nil and &1.rssi_dbm != nil))
+ |> Enum.group_by(& &1.channel)
+
+ Map.new(candidates, fn ch ->
+ score =
+ by_channel
+ |> Map.get(ch, [])
+ |> Enum.reduce(0.0, fn s, acc -> acc + linear_power(s.rssi_dbm) end)
+
+ {ch, score}
+ end)
+ end
+
+ # Convert dBm to a linear power-like contribution. Stronger signals
+ # contribute much more than weak ones (10 dB ≈ 10× contribution).
+ defp linear_power(rssi_dbm) do
+ :math.pow(10.0, (rssi_dbm + 95) / 10.0)
+ end
+
+ defp score_to_db(score) when is_number(score) and score > 0 do
+ 10.0 * :math.log10(score)
+ end
+
+ defp score_to_db(_), do: 0.0
+
+ defp improvement_worth_it?(current, recommended) when is_number(current) and is_number(recommended) do
+ score_to_db(current) - score_to_db(recommended) >= @min_improvement_db
+ end
+
+ defp urgency_for(current_score) when current_score >= @critical_score, do: "critical"
+ defp urgency_for(_), do: "warning"
+
+ defp top_offenders(scans, current_channel) do
+ scans
+ |> Enum.filter(&(&1.channel == current_channel and not is_nil(&1.rssi_dbm)))
+ |> Enum.sort_by(& &1.rssi_dbm, :desc)
+ |> Enum.take(@max_offenders)
+ |> Enum.map(fn s ->
+ %{
+ "bssid" => s.bssid,
+ "ssid" => s.ssid,
+ "rssi_dbm" => s.rssi_dbm,
+ "channel" => s.channel,
+ "frequency_mhz" => s.frequency_mhz,
+ "is_own_fleet" => s.is_own_fleet,
+ "seen_at" => s.seen_at && DateTime.to_iso8601(s.seen_at)
+ }
+ end)
+ end
+end
diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex
index fa9a89ba..e82397bb 100644
--- a/lib/towerops/snmp/device.ex
+++ b/lib/towerops/snmp/device.ex
@@ -36,6 +36,13 @@ defmodule Towerops.Snmp.Device do
field :raw_discovery_data, :map
field :last_discovery_at, :utc_datetime
+ # Current radio configuration (populated by vendor SNMP poller profiles
+ # for APs that support wireless OIDs).
+ field :current_channel, :integer
+ field :current_frequency_mhz, :integer
+ field :current_channel_width_mhz, :integer
+ field :last_radio_seen_at, :utc_datetime
+
belongs_to :device, DeviceSchema
has_many :interfaces, Interface, foreign_key: :snmp_device_id
@@ -64,6 +71,10 @@ defmodule Towerops.Snmp.Device do
serial_number: String.t() | nil,
raw_discovery_data: map() | nil,
last_discovery_at: DateTime.t() | nil,
+ current_channel: integer() | nil,
+ current_frequency_mhz: integer() | nil,
+ current_channel_width_mhz: integer() | nil,
+ last_radio_seen_at: DateTime.t() | nil,
device_id: Ecto.UUID.t(),
device: NotLoaded.t() | DeviceSchema.t(),
interfaces: NotLoaded.t() | [Interface.t()],
@@ -94,7 +105,11 @@ defmodule Towerops.Snmp.Device do
:firmware_version,
:serial_number,
:raw_discovery_data,
- :last_discovery_at
+ :last_discovery_at,
+ :current_channel,
+ :current_frequency_mhz,
+ :current_channel_width_mhz,
+ :last_radio_seen_at
])
|> validate_required([:device_id])
|> unique_constraint(:device_id)
diff --git a/lib/towerops/snmp/wireless_neighbor_scan.ex b/lib/towerops/snmp/wireless_neighbor_scan.ex
new file mode 100644
index 00000000..351c4407
--- /dev/null
+++ b/lib/towerops/snmp/wireless_neighbor_scan.ex
@@ -0,0 +1,59 @@
+defmodule Towerops.Snmp.WirelessNeighborScan do
+ @moduledoc """
+ An observation of a neighboring access point seen by one of our APs
+ during a wireless scan (vendor-specific SNMP `*WlSurveyTable` /
+ `cambium*Neighbor*` / etc.).
+
+ Append-only — multiple rows per (device, bssid) accumulate over time
+ so the recommendation engine can reason about RF environment trends.
+
+ `is_own_fleet` is true when the observed BSSID matches the MAC of
+ another device in the same organization (set at write time, not at
+ query time, so we can index it cheaply if needed later).
+ """
+
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @type t :: %__MODULE__{}
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ schema "wireless_neighbor_scans" do
+ field :bssid, :string
+ field :ssid, :string
+ field :channel, :integer
+ field :frequency_mhz, :integer
+ field :channel_width_mhz, :integer
+ field :rssi_dbm, :integer
+ field :is_own_fleet, :boolean, default: false
+ field :seen_at, :utc_datetime
+
+ belongs_to :device, Towerops.Devices.Device
+ belongs_to :organization, Towerops.Organizations.Organization
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ @doc false
+ def changeset(scan, attrs) do
+ scan
+ |> cast(attrs, [
+ :device_id,
+ :organization_id,
+ :bssid,
+ :ssid,
+ :channel,
+ :frequency_mhz,
+ :channel_width_mhz,
+ :rssi_dbm,
+ :is_own_fleet,
+ :seen_at
+ ])
+ |> validate_required([:device_id, :organization_id, :bssid, :seen_at])
+ |> foreign_key_constraint(:device_id)
+ |> foreign_key_constraint(:organization_id)
+ end
+end
diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex
new file mode 100644
index 00000000..3bef636f
--- /dev/null
+++ b/lib/towerops/workers/recommendations_run_worker.ex
@@ -0,0 +1,51 @@
+defmodule Towerops.Workers.RecommendationsRunWorker do
+ @moduledoc """
+ Oban cron worker that runs all recommendation rules across organizations.
+
+ Each rule is a pure function that returns a list of insight attribute
+ maps. We upsert via `Towerops.Preseem.Insights.insert_insight_if_new/1`,
+ which dedups on `(device_id, type, status="active")` so re-runs of the
+ same rule against the same AP do not produce duplicates.
+
+ New rules can be added by appending to `@rules`. They must implement
+ `evaluate(organization_id) :: [insight_attrs]`.
+ """
+
+ use Oban.Worker, queue: :maintenance, max_attempts: 3
+
+ import Ecto.Query
+
+ alias Towerops.Organizations.Organization
+ alias Towerops.Preseem.Insights
+ alias Towerops.Recommendations.Rules.FrequencyChange
+ alias Towerops.Repo
+
+ require Logger
+
+ @rules [FrequencyChange]
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ Enum.each(list_organization_ids(), &run_rules_for_org/1)
+ :ok
+ end
+
+ defp list_organization_ids do
+ Organization
+ |> select([o], o.id)
+ |> Repo.all()
+ end
+
+ defp run_rules_for_org(organization_id) do
+ Enum.each(@rules, fn rule ->
+ try do
+ organization_id |> rule.evaluate() |> Enum.each(&Insights.insert_insight_if_new/1)
+ rescue
+ e ->
+ Logger.warning(
+ "Recommendation rule #{inspect(rule)} failed for org #{organization_id}: #{Exception.message(e)}"
+ )
+ end
+ end)
+ end
+end
diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex
index 7f174176..337367ed 100644
--- a/lib/towerops_web/live/insights_live/index.html.heex
+++ b/lib/towerops_web/live/insights_live/index.html.heex
@@ -392,6 +392,69 @@
<% end %>
+ <%= if insight.type == "ap_frequency_change" do %>
+
+
+
+
+ {t("Current channel")}
+
+
+ {insight.metadata["current_channel"]}
+
+
+ <.icon
+ name="hero-arrow-right"
+ class="h-4 w-4 text-purple-500 dark:text-purple-400"
+ />
+
+
+ {t("Recommended")}
+
+
+ {insight.metadata["recommended_channel"]}
+
+
+ <%= if insight.metadata["improvement_db"] do %>
+
+ {Float.round(insight.metadata["improvement_db"] / 1.0, 1)} dB cleaner
+
+ <% end %>
+
+
+ <%= if insight.metadata["top_offenders"] && insight.metadata["top_offenders"] != [] do %>
+
+
+ {t("Top interferers on current channel")}
+
+
+ -
+
+ {offender["bssid"]}
+ <%= if offender["ssid"] do %>
+
+ {offender["ssid"]}
+
+ <% end %>
+ <%= if offender["is_own_fleet"] do %>
+
+ own fleet
+
+ <% end %>
+
+
+ {offender["rssi_dbm"]} dBm
+
+
+
+
+ <% end %>
+
+ <% end %>
+
<%!-- Access point --%>
<%= if insight.preseem_access_point do %>
diff --git a/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs b/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs
new file mode 100644
index 00000000..112a0686
--- /dev/null
+++ b/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs
@@ -0,0 +1,31 @@
+defmodule Towerops.Repo.Migrations.CreateWirelessNeighborScans do
+ use Ecto.Migration
+
+ def change do
+ create table(:wireless_neighbor_scans, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
+
+ add :organization_id,
+ references(:organizations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :bssid, :string, null: false
+ add :ssid, :string
+ add :channel, :integer
+ add :frequency_mhz, :integer
+ add :channel_width_mhz, :integer
+ add :rssi_dbm, :integer
+ add :is_own_fleet, :boolean, default: false, null: false
+ add :seen_at, :utc_datetime, null: false
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ create index(:wireless_neighbor_scans, [:device_id, :seen_at])
+ create index(:wireless_neighbor_scans, [:device_id, :channel])
+ create index(:wireless_neighbor_scans, [:bssid])
+ create index(:wireless_neighbor_scans, [:organization_id, :seen_at])
+ end
+end
diff --git a/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs b/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs
new file mode 100644
index 00000000..e3e3c29d
--- /dev/null
+++ b/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs
@@ -0,0 +1,14 @@
+defmodule Towerops.Repo.Migrations.AddRadioFieldsToSnmpDevices do
+ use Ecto.Migration
+
+ def change do
+ alter table(:snmp_devices) do
+ add :current_channel, :integer
+ add :current_frequency_mhz, :integer
+ add :current_channel_width_mhz, :integer
+ add :last_radio_seen_at, :utc_datetime
+ end
+
+ create index(:snmp_devices, [:current_channel])
+ end
+end
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt
index 2c38a482..f70dd7fd 100644
--- a/priv/static/changelog.txt
+++ b/priv/static/changelog.txt
@@ -1,4 +1,5 @@
-2026-05-09 — AI-generated insight summaries
+2026-05-09 — Frequency-change recommendations + AI-generated insight summaries
+* New recommendation: when a wireless AP has strong same-channel interference, the system suggests a specific cleaner channel and shows the top interfering neighbors with their signal levels
* Active insights now include a plain-language summary and a specific recommended action
* AI summaries refresh automatically — no setup required for users
diff --git a/test/towerops/recommendations/rules/frequency_change_test.exs b/test/towerops/recommendations/rules/frequency_change_test.exs
new file mode 100644
index 00000000..588fd07c
--- /dev/null
+++ b/test/towerops/recommendations/rules/frequency_change_test.exs
@@ -0,0 +1,183 @@
+defmodule Towerops.Recommendations.Rules.FrequencyChangeTest do
+ @moduledoc """
+ Pure-function tests for the FrequencyChange rule.
+
+ Builds a small fixture: one observing AP with a current channel, plus
+ a set of `wireless_neighbor_scans` rows representing the RF environment.
+ Asserts the rule produces (or skips) an `ap_frequency_change` insight
+ with the expected evidence.
+ """
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.DevicesFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Recommendations.Rules.FrequencyChange
+ alias Towerops.Snmp.Device, as: SnmpDevice
+ alias Towerops.Snmp.WirelessNeighborScan
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ device = device_fixture(%{organization_id: org.id, name: "AP-North"})
+
+ {:ok, snmp} =
+ %SnmpDevice{}
+ |> SnmpDevice.changeset(%{
+ device_id: device.id,
+ sys_name: "AP-North",
+ current_channel: 149,
+ current_frequency_mhz: 5745,
+ current_channel_width_mhz: 80,
+ last_radio_seen_at: ~U[2026-05-09 12:00:00Z]
+ })
+ |> Repo.insert()
+
+ %{org: org, device: device, snmp: snmp}
+ end
+
+ defp insert_scan(org, device, attrs) do
+ full =
+ Map.merge(
+ %{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "AA:BB:CC:DD:EE:FF",
+ channel: 149,
+ frequency_mhz: 5745,
+ channel_width_mhz: 80,
+ rssi_dbm: -70,
+ is_own_fleet: false,
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ },
+ attrs
+ )
+
+ %WirelessNeighborScan{}
+ |> WirelessNeighborScan.changeset(full)
+ |> Repo.insert!()
+ end
+
+ test "returns no insight when there are no neighbor scans", %{org: org} do
+ assert FrequencyChange.evaluate(org.id) == []
+ end
+
+ test "returns no insight when same-channel neighbors are below the threshold",
+ %{org: org, device: device} do
+ insert_scan(org, device, %{channel: 149, rssi_dbm: -85})
+
+ assert FrequencyChange.evaluate(org.id) == []
+ end
+
+ test "produces an insight when a strong same-channel neighbor exists",
+ %{org: org, device: device} do
+ insert_scan(org, device, %{
+ bssid: "11:22:33:44:55:66",
+ ssid: "Neighbor-A",
+ channel: 149,
+ rssi_dbm: -62
+ })
+
+ # Add a clean alternative channel — no neighbors observed there
+ insert_scan(org, device, %{
+ bssid: "11:22:33:44:55:77",
+ ssid: "Neighbor-B",
+ channel: 153,
+ rssi_dbm: -90
+ })
+
+ assert [insight] = FrequencyChange.evaluate(org.id)
+ assert insight.type == "ap_frequency_change"
+ assert insight.urgency in ~w(warning critical)
+ assert insight.organization_id == org.id
+ assert insight.device_id == device.id
+
+ md = insight.metadata
+ assert md["current_channel"] == 149
+ assert md["current_frequency_mhz"] == 5745
+ assert md["channel_width_mhz"] == 80
+ assert is_integer(md["recommended_channel"])
+ assert md["recommended_channel"] != 149
+ assert is_number(md["current_score"])
+ assert is_number(md["recommended_score"])
+ assert md["current_score"] > md["recommended_score"]
+ assert is_list(md["top_offenders"])
+ refute Enum.empty?(md["top_offenders"])
+ [first | _] = md["top_offenders"]
+ assert first["bssid"] == "11:22:33:44:55:66"
+ assert first["rssi_dbm"] == -62
+
+ assert is_binary(insight.title)
+ assert insight.title =~ "AP-North"
+ assert insight.title =~ "149"
+ end
+
+ test "marks urgency critical when current interference score is very high",
+ %{org: org, device: device} do
+ # Many strong same-channel neighbors → score far above critical threshold
+ for i <- 1..6 do
+ insert_scan(org, device, %{
+ bssid: "AA:BB:CC:00:00:0#{i}",
+ channel: 149,
+ rssi_dbm: -55
+ })
+ end
+
+ insert_scan(org, device, %{bssid: "11:11:11:11:11:11", channel: 153, rssi_dbm: -90})
+
+ assert [insight] = FrequencyChange.evaluate(org.id)
+ assert insight.urgency == "critical"
+ end
+
+ test "top_offenders is sorted by RSSI descending and capped at 5",
+ %{org: org, device: device} do
+ rssis = [-78, -65, -62, -70, -82, -55, -90]
+
+ for {rssi, i} <- Enum.with_index(rssis) do
+ insert_scan(org, device, %{
+ bssid: "AA:BB:CC:DD:00:0#{i}",
+ channel: 149,
+ rssi_dbm: rssi
+ })
+ end
+
+ assert [insight] = FrequencyChange.evaluate(org.id)
+ offenders = insight.metadata["top_offenders"]
+ assert length(offenders) == 5
+
+ rssi_values = Enum.map(offenders, & &1["rssi_dbm"])
+ assert rssi_values == Enum.sort(rssi_values, :desc)
+ # Strongest signal should be first
+ assert hd(rssi_values) == -55
+ end
+
+ test "ignores APs without a current_channel", %{org: org, device: device} do
+ # Disable the radio data on our AP
+ Repo.update_all(SnmpDevice, set: [current_channel: nil, current_frequency_mhz: nil])
+
+ insert_scan(org, device, %{channel: 149, rssi_dbm: -55})
+
+ assert FrequencyChange.evaluate(org.id) == []
+ end
+
+ test "ignores neighbor scans older than 24h", %{org: org, device: device} do
+ old = DateTime.add(DateTime.utc_now(), -25 * 3600, :second)
+
+ insert_scan(org, device, %{
+ channel: 149,
+ rssi_dbm: -55,
+ seen_at: DateTime.truncate(old, :second)
+ })
+
+ assert FrequencyChange.evaluate(org.id) == []
+ end
+
+ test "scopes to the given organization", %{org: org, device: device} do
+ insert_scan(org, device, %{channel: 149, rssi_dbm: -55})
+
+ other_user = user_fixture()
+ other_org = organization_fixture(other_user.id)
+ assert FrequencyChange.evaluate(other_org.id) == []
+ end
+end
diff --git a/test/towerops/snmp/device_radio_fields_test.exs b/test/towerops/snmp/device_radio_fields_test.exs
new file mode 100644
index 00000000..74494246
--- /dev/null
+++ b/test/towerops/snmp/device_radio_fields_test.exs
@@ -0,0 +1,58 @@
+defmodule Towerops.Snmp.DeviceRadioFieldsTest do
+ @moduledoc """
+ Coverage for the radio fields added to snmp_devices to support
+ RF/frequency-based recommendations.
+ """
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.DevicesFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Snmp.Device, as: SnmpDevice
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ device = device_fixture(%{organization_id: org.id})
+ %{device: device}
+ end
+
+ describe "changeset/2 with radio fields" do
+ test "casts and round-trips current_channel/frequency/width and last_radio_seen_at",
+ %{device: device} do
+ attrs = %{
+ device_id: device.id,
+ sys_name: "ap1",
+ current_channel: 149,
+ current_frequency_mhz: 5745,
+ current_channel_width_mhz: 80,
+ last_radio_seen_at: ~U[2026-05-09 12:00:00Z]
+ }
+
+ assert {:ok, snmp} =
+ %SnmpDevice{}
+ |> SnmpDevice.changeset(attrs)
+ |> Repo.insert()
+
+ assert snmp.current_channel == 149
+ assert snmp.current_frequency_mhz == 5745
+ assert snmp.current_channel_width_mhz == 80
+ assert snmp.last_radio_seen_at == ~U[2026-05-09 12:00:00Z]
+ end
+
+ test "radio fields are optional", %{device: device} do
+ attrs = %{device_id: device.id, sys_name: "ap-no-radio"}
+
+ assert {:ok, snmp} =
+ %SnmpDevice{}
+ |> SnmpDevice.changeset(attrs)
+ |> Repo.insert()
+
+ assert is_nil(snmp.current_channel)
+ assert is_nil(snmp.current_frequency_mhz)
+ assert is_nil(snmp.current_channel_width_mhz)
+ assert is_nil(snmp.last_radio_seen_at)
+ end
+ end
+end
diff --git a/test/towerops/snmp/wireless_neighbor_scan_test.exs b/test/towerops/snmp/wireless_neighbor_scan_test.exs
new file mode 100644
index 00000000..6600715f
--- /dev/null
+++ b/test/towerops/snmp/wireless_neighbor_scan_test.exs
@@ -0,0 +1,117 @@
+defmodule Towerops.Snmp.WirelessNeighborScanTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.DevicesFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Snmp.WirelessNeighborScan
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ device = device_fixture(%{organization_id: org.id})
+ %{org: org, device: device}
+ end
+
+ describe "changeset/2" do
+ test "valid changeset with all fields", %{org: org, device: device} do
+ attrs = %{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "AA:BB:CC:11:22:33",
+ ssid: "neighbor-ap",
+ channel: 149,
+ frequency_mhz: 5745,
+ channel_width_mhz: 80,
+ rssi_dbm: -62,
+ is_own_fleet: true,
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ }
+
+ changeset = WirelessNeighborScan.changeset(%WirelessNeighborScan{}, attrs)
+ assert changeset.valid?
+
+ assert {:ok, scan} = Repo.insert(changeset)
+ assert scan.bssid == "AA:BB:CC:11:22:33"
+ assert scan.is_own_fleet == true
+ assert scan.rssi_dbm == -62
+ end
+
+ test "minimal changeset (just required fields)", %{org: org, device: device} do
+ attrs = %{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "DE:AD:BE:EF:00:01",
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ }
+
+ changeset = WirelessNeighborScan.changeset(%WirelessNeighborScan{}, attrs)
+ assert changeset.valid?
+ assert {:ok, _scan} = Repo.insert(changeset)
+ end
+
+ test "is_own_fleet defaults to false", %{org: org, device: device} do
+ attrs = %{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "AA:BB:CC:DD:EE:FF",
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ }
+
+ assert {:ok, scan} =
+ %WirelessNeighborScan{}
+ |> WirelessNeighborScan.changeset(attrs)
+ |> Repo.insert()
+
+ assert scan.is_own_fleet == false
+ end
+
+ test "requires device_id" do
+ changeset =
+ WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{
+ bssid: "AA:BB:CC:DD:EE:FF",
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ })
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).device_id
+ end
+
+ test "requires organization_id", %{device: device} do
+ changeset =
+ WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{
+ device_id: device.id,
+ bssid: "AA:BB:CC:DD:EE:FF",
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ })
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).organization_id
+ end
+
+ test "requires bssid", %{org: org, device: device} do
+ changeset =
+ WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{
+ device_id: device.id,
+ organization_id: org.id,
+ seen_at: ~U[2026-05-09 12:00:00Z]
+ })
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).bssid
+ end
+
+ test "requires seen_at", %{org: org, device: device} do
+ changeset =
+ WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "AA:BB:CC:DD:EE:FF"
+ })
+
+ refute changeset.valid?
+ assert "can't be blank" in errors_on(changeset).seen_at
+ end
+ end
+end
diff --git a/test/towerops/workers/recommendations_run_worker_test.exs b/test/towerops/workers/recommendations_run_worker_test.exs
new file mode 100644
index 00000000..97e48f28
--- /dev/null
+++ b/test/towerops/workers/recommendations_run_worker_test.exs
@@ -0,0 +1,84 @@
+defmodule Towerops.Workers.RecommendationsRunWorkerTest do
+ @moduledoc """
+ Drives the cron worker that fans out recommendation rule evaluation
+ across organizations and upserts the resulting insights.
+ """
+ use Towerops.DataCase, async: false
+
+ import Towerops.AccountsFixtures
+ import Towerops.DevicesFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Preseem.Insight
+ alias Towerops.Preseem.Insights
+ alias Towerops.Snmp.Device, as: SnmpDevice
+ alias Towerops.Snmp.WirelessNeighborScan
+ alias Towerops.Workers.RecommendationsRunWorker
+
+ defp setup_ap_with_strong_neighbor(org) do
+ device = device_fixture(%{organization_id: org.id, name: "AP-X"})
+
+ {:ok, _snmp} =
+ %SnmpDevice{}
+ |> SnmpDevice.changeset(%{
+ device_id: device.id,
+ sys_name: "AP-X",
+ current_channel: 149,
+ current_frequency_mhz: 5745,
+ current_channel_width_mhz: 80,
+ last_radio_seen_at: ~U[2026-05-09 12:00:00Z]
+ })
+ |> Repo.insert()
+
+ %WirelessNeighborScan{}
+ |> WirelessNeighborScan.changeset(%{
+ device_id: device.id,
+ organization_id: org.id,
+ bssid: "AA:BB:CC:DD:EE:FF",
+ channel: 149,
+ rssi_dbm: -55,
+ seen_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ |> Repo.insert!()
+
+ device
+ end
+
+ test "perform/1 generates ap_frequency_change insights for orgs with offending APs" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ device = setup_ap_with_strong_neighbor(org)
+
+ assert :ok = RecommendationsRunWorker.perform(%Oban.Job{})
+
+ insight = Repo.get_by(Insight, organization_id: org.id, type: "ap_frequency_change")
+ assert insight
+ assert insight.device_id == device.id
+ assert insight.metadata["current_channel"] == 149
+ assert insight.metadata["recommended_channel"] != 149
+ end
+
+ test "perform/1 is idempotent: running twice does not duplicate insights" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ setup_ap_with_strong_neighbor(org)
+
+ assert :ok = RecommendationsRunWorker.perform(%Oban.Job{})
+ assert :ok = RecommendationsRunWorker.perform(%Oban.Job{})
+
+ insights =
+ Insights.list_insights(org.id, type: "ap_frequency_change")
+
+ assert length(insights) == 1
+ end
+
+ test "perform/1 produces no insights when there are no qualifying APs" do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+ _quiet_device = device_fixture(%{organization_id: org.id})
+
+ assert :ok = RecommendationsRunWorker.perform(%Oban.Job{})
+
+ assert Insights.list_insights(org.id, type: "ap_frequency_change") == []
+ end
+end
diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs
index d47d6925..9a63615e 100644
--- a/test/towerops_web/live/agent_live_test.exs
+++ b/test/towerops_web/live/agent_live_test.exs
@@ -6,6 +6,7 @@ defmodule ToweropsWeb.AgentLiveTest do
import Phoenix.LiveViewTest
alias Towerops.Agents
+ alias Towerops.Agents.ReleaseChecker
setup :register_and_log_in_user
@@ -572,8 +573,20 @@ defmodule ToweropsWeb.AgentLiveTest do
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
+ # Force a transport error from the release-checker HTTP client so the
+ # test is deterministic. Without an explicit stub, behavior depends on
+ # whether other tests have left a Req.Test stub registered for this
+ # module — which makes this test flaky under the full suite.
+ Req.Test.stub(ReleaseChecker, fn conn ->
+ Req.Test.transport_error(conn, :econnrefused)
+ end)
+
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
+ # Allow the LiveView process to use stubs registered in this test
+ # process (Req.Test.stub is owner-scoped by default).
+ Req.Test.allow(ReleaseChecker, self(), view.pid)
+
html =
view
|> element("button", "Update")
diff --git a/test/towerops_web/live/insights_live_events_test.exs b/test/towerops_web/live/insights_live_events_test.exs
index b0dc95fd..781618f8 100644
--- a/test/towerops_web/live/insights_live_events_test.exs
+++ b/test/towerops_web/live/insights_live_events_test.exs
@@ -136,4 +136,55 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
refute html =~ "AI-generated"
end
end
+
+ describe "ap_frequency_change rendering" do
+ test "shows current vs recommended channel and the top offender", %{
+ conn: conn,
+ organization: organization
+ } do
+ {:ok, _} =
+ Repo.insert(
+ Insight.changeset(%Insight{}, %{
+ organization_id: organization.id,
+ type: "ap_frequency_change",
+ urgency: "warning",
+ status: "active",
+ channel: "proactive",
+ source: "system",
+ title: "AP-North should change channel: 149 → 161",
+ metadata: %{
+ "current_channel" => 149,
+ "recommended_channel" => 161,
+ "current_score" => 25.4,
+ "recommended_score" => 1.0,
+ "improvement_db" => 14.0,
+ "channel_width_mhz" => 80,
+ "current_frequency_mhz" => 5745,
+ "top_offenders" => [
+ %{
+ "bssid" => "AA:BB:CC:DD:EE:FF",
+ "ssid" => "Neighbor-A",
+ "rssi_dbm" => -55,
+ "channel" => 149,
+ "frequency_mhz" => 5745,
+ "is_own_fleet" => true,
+ "seen_at" => "2026-05-09T12:00:00Z"
+ }
+ ]
+ }
+ })
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/insights")
+
+ assert html =~ "Current channel"
+ assert html =~ "149"
+ assert html =~ "161"
+ assert html =~ "AA:BB:CC:DD:EE:FF"
+ assert html =~ "Neighbor-A"
+ assert html =~ "-55 dBm"
+ assert html =~ "own fleet"
+ assert html =~ "14.0 dB cleaner"
+ end
+ end
end
diff --git a/test/towerops_web/telemetry_test.exs b/test/towerops_web/telemetry_test.exs
index c837689d..5f920f1f 100644
--- a/test/towerops_web/telemetry_test.exs
+++ b/test/towerops_web/telemetry_test.exs
@@ -157,7 +157,13 @@ defmodule ToweropsWeb.TelemetryTest do
)
end)
- assert log == ""
+ # capture_log catches every log line emitted by *any* process while the
+ # function runs, so a stray DB sandbox or Postgrex disconnect log from
+ # a concurrent test can show up here. Assert on what this function
+ # itself does NOT log instead of demanding empty output.
+ refute log =~ "Slow request"
+ refute log =~ "Server error"
+ refute log =~ "/fast-endpoint"
end
test "logs both warning and error for slow 5xx request" do