feat(snmp): canonical AP radio state + own-fleet channel conflict rule

Adds a vendor-agnostic radio-state extractor that lets the frequency-
aware rules actually fire on production data without per-vendor
plumbing for each profile.

- Towerops.Snmp.RadioState — pure module that maps a frequency in MHz
  to the corresponding 802.11 channel across 2.4 GHz, 5 GHz, and 6 GHz
  bands; reads the freshest "frequency" sensor (already emitted by
  Cambium/MikroTik/AirOS/Mimosa/Exalt/etc. profiles) and persists the
  AP-level current_frequency_mhz, current_channel, last_radio_seen_at
  fields onto snmp_devices.
- DevicePollerWorker now calls RadioState.update_from_sensors/1 after
  each sensor poll so frequency-aware rules read a single canonical
  source instead of joining sensors at query time.
- Towerops.Recommendations.Rules.OwnFleetChannelConflict — new rule
  that needs no neighbor-scan collection: detects when 2+ of our own
  APs at the same site share a channel (self-interference). One
  insight per (site, channel) with a clickable list of conflicting
  APs and their channel widths. Critical when 3+ APs share a channel.
- Insight.@valid_types extended with own_fleet_channel_conflict.
- UI: amber card showing channel, frequency, AP count, and the linked
  list of conflicting devices.

Wired into the existing hourly RecommendationsRunWorker. LLM
enrichment automatically applies via the Phase 1 worker.
This commit is contained in:
Graham McIntire 2026-05-09 17:14:48 -05:00
parent 91f23416e4
commit 18f62022f5
10 changed files with 499 additions and 2 deletions

View file

@ -1,3 +1,22 @@
2026-05-09
feat(snmp): canonical AP radio state + own-fleet channel conflict rule
Towerops.Snmp.RadioState — derives current_channel from existing
"frequency" sensors (MHz) emitted by every wireless vendor profile.
Maps frequencies in 2.4 GHz / 5 GHz / 6 GHz UNII bands to channel
numbers; stores frequency, channel, and last_radio_seen_at on
snmp_devices. Called from DevicePollerWorker after each poll cycle
so AP-level radio state stays fresh.
Towerops.Recommendations.Rules.OwnFleetChannelConflict — new rule
that needs no neighbor-scan data, just the now-populated
current_channel. Detects when 2+ of our own APs at the same site
share a channel (self-interference signature). Emits one insight
per (site, channel) group with a clickable list of conflicting
APs and their channel widths. Critical urgency when 3+ APs share
a channel.
Insight.@valid_types extended with own_fleet_channel_conflict.
UI: amber card with channel, frequency, AP-count, and a clickable
list of conflicting devices.
2026-05-09
feat(insights): two more recommendation rules — SectorOverload, CpeRealign
Towerops.Recommendations.Rules.SectorOverload — fires when a Preseem

View file

@ -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 ap_frequency_change sector_overload cpe_realign)
@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 sector_overload cpe_realign own_fleet_channel_conflict)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)

View file

@ -0,0 +1,85 @@
defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do
@moduledoc """
Detects when two or more of our own access points at the same site
are configured on the same channel.
Without GPS sync this guarantees self-interference between sectors;
even with GPS sync it usually means a frequency-plan oversight. This
rule needs no neighbor scan data just the `current_channel` already
populated on `snmp_devices` by `Towerops.Snmp.RadioState`.
Emits one `own_fleet_channel_conflict` insight per (site, channel)
group, listing the conflicting APs in the metadata so the operator
can pick which one to move.
"""
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
@spec evaluate(Ecto.UUID.t()) :: [map()]
def evaluate(organization_id) when is_binary(organization_id) do
organization_id
|> load_aps_with_channel()
|> Enum.group_by(fn {device, snmp} -> {device.site_id, snmp.current_channel} end)
|> Enum.reject(fn {{site_id, _channel}, group} ->
is_nil(site_id) or length(group) < 2
end)
|> Enum.map(&build_insight(&1, organization_id))
end
defp load_aps_with_channel(organization_id) do
Repo.all(
from(d in Device,
join: s in SnmpDevice,
on: s.device_id == d.id,
where: d.organization_id == ^organization_id and not is_nil(s.current_channel) and not is_nil(d.site_id),
select: {d, s}
)
)
end
defp build_insight({{site_id, channel}, group}, organization_id) do
[{first_device, first_snmp} | _] = group
aps =
Enum.map(group, fn {device, snmp} ->
%{
"device_id" => device.id,
"name" => device.name,
"channel" => snmp.current_channel,
"frequency_mhz" => snmp.current_frequency_mhz,
"channel_width_mhz" => snmp.current_channel_width_mhz
}
end)
%{
organization_id: organization_id,
site_id: site_id,
device_id: first_device.id,
type: "own_fleet_channel_conflict",
urgency: urgency_for(length(group)),
channel: "proactive",
source: "system",
title:
"#{length(group)} APs at the same site share channel #{channel} " <>
"(#{first_snmp.current_frequency_mhz} MHz)",
description:
"Multiple access points at the same site are configured on the " <>
"same channel. Without strict GPS sync this causes co-channel " <>
"interference between sectors and degrades airtime efficiency.",
metadata: %{
"dedup_key" => "own_fleet_channel_conflict:#{site_id}:#{channel}",
"channel" => channel,
"frequency_mhz" => first_snmp.current_frequency_mhz,
"ap_count" => length(group),
"aps" => aps
}
}
end
defp urgency_for(count) when count >= 3, do: "critical"
defp urgency_for(_), do: "warning"
end

View file

@ -0,0 +1,99 @@
defmodule Towerops.Snmp.RadioState do
@moduledoc """
Helper that maintains the current radio state (channel + frequency)
on `snmp_devices` rows by reading the freshest `frequency` sensor.
Vendor profiles already extract per-radio frequency into the existing
`snmp_sensors` table (sensor_type "frequency", value in MHz). This
module derives the canonical AP-level radio state from that data so
frequency-aware recommendation rules can read a single column instead
of joining sensors.
Call `update_from_sensors/1` after a poll cycle to keep the snmp_device
row in sync. It is a no-op for devices that don't expose any frequency
sensors.
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Sensor
@type freq_input :: number() | binary() | nil
@doc """
Convert a frequency in MHz to the corresponding 802.11 channel number.
Returns nil if the frequency is outside the supported bands or is not
a positive number.
"""
@spec frequency_to_channel(freq_input()) :: integer() | nil
def frequency_to_channel(freq) when is_binary(freq) do
case Integer.parse(freq) do
{n, _} -> frequency_to_channel(n)
:error -> nil
end
end
def frequency_to_channel(freq) when is_float(freq) do
frequency_to_channel(round(freq))
end
def frequency_to_channel(freq) when is_integer(freq) and freq > 0 do
channel_24ghz(freq) || channel_6ghz(freq) || channel_5ghz(freq)
end
def frequency_to_channel(_), do: nil
# 2.4 GHz: 2412→1, 2417→2, ..., 2472→13, 2484→14
defp channel_24ghz(2484), do: 14
defp channel_24ghz(freq) when freq >= 2412 and freq <= 2472,
do: if(rem(freq - 2412, 5) == 0, do: div(freq - 2412, 5) + 1)
defp channel_24ghz(_), do: nil
# 6 GHz UNII-5/6: starts at 5955 MHz channel 1, then 20 MHz spacing
defp channel_6ghz(freq) when freq >= 5955 and freq <= 7115, do: div(freq - 5950, 5)
defp channel_6ghz(_), do: nil
# 5 GHz UNII-1..3: ch = (f - 5000) / 5
defp channel_5ghz(freq) when freq >= 5170 and freq <= 5895, do: div(freq - 5000, 5)
defp channel_5ghz(_), do: nil
@doc """
Read the most recent `frequency` sensor for the given snmp_device and
update its current_channel / current_frequency_mhz / last_radio_seen_at
fields. Returns `{:ok, updated_snmp_device}` even when nothing changed.
"""
@spec update_from_sensors(SnmpDevice.t()) :: {:ok, SnmpDevice.t()} | {:error, term()}
def update_from_sensors(%SnmpDevice{} = snmp) do
case latest_frequency_sensor(snmp.id) do
nil ->
{:ok, snmp}
%Sensor{last_value: value} when is_number(value) and value > 0 ->
freq = round(value)
channel = frequency_to_channel(freq)
now = DateTime.truncate(DateTime.utc_now(), :second)
snmp
|> SnmpDevice.changeset(%{
current_frequency_mhz: freq,
current_channel: channel,
last_radio_seen_at: now
})
|> Repo.update()
end
end
defp latest_frequency_sensor(snmp_device_id) do
Sensor
|> where([s], s.snmp_device_id == ^snmp_device_id and s.sensor_type == "frequency")
|> where([s], not is_nil(s.last_value) and s.last_value > ^0.0)
|> order_by([s], desc: s.updated_at)
|> limit(1)
|> Repo.one()
end
end

View file

@ -34,6 +34,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
alias Towerops.Snmp.Client
alias Towerops.Snmp.MacDiscovery
alias Towerops.Snmp.NeighborDiscovery
alias Towerops.Snmp.RadioState
alias Towerops.Snmp.SensorChangeDetector
alias Towerops.Snmp.WirelessClientDiscovery
alias Towerops.Workers.PollingOffset
@ -278,6 +279,11 @@ defmodule Towerops.Workers.DevicePollerWorker do
poll_sensors(snmp_device.sensors, client_opts, now)
Logger.debug("Polled #{length(snmp_device.sensors)} sensors for #{device.name}")
# Refresh AP-level radio state (current_channel / current_frequency_mhz)
# from the just-polled frequency sensors so frequency-aware rules read
# a single canonical source.
_ = RadioState.update_from_sensors(snmp_device)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,

View file

@ -19,12 +19,13 @@ defmodule Towerops.Workers.RecommendationsRunWorker do
alias Towerops.Preseem.Insights
alias Towerops.Recommendations.Rules.CpeRealign
alias Towerops.Recommendations.Rules.FrequencyChange
alias Towerops.Recommendations.Rules.OwnFleetChannelConflict
alias Towerops.Recommendations.Rules.SectorOverload
alias Towerops.Repo
require Logger
@rules [FrequencyChange, SectorOverload, CpeRealign]
@rules [FrequencyChange, OwnFleetChannelConflict, SectorOverload, CpeRealign]
@impl Oban.Worker
def perform(%Oban.Job{}) do

View file

@ -480,6 +480,63 @@
</div>
<% end %>
<%= if insight.type == "own_fleet_channel_conflict" do %>
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<div class="flex items-center gap-3 text-sm">
<div class="flex flex-col">
<span class="text-xs text-amber-700 dark:text-amber-300">
{t("Channel")}
</span>
<span class="font-mono text-base font-semibold text-amber-900 dark:text-amber-100">
{insight.metadata["channel"]}
<%= if insight.metadata["frequency_mhz"] do %>
<span class="text-xs font-normal text-amber-700 dark:text-amber-300">
({insight.metadata["frequency_mhz"]} MHz)
</span>
<% end %>
</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-amber-700 dark:text-amber-300">
{t("APs sharing")}
</span>
<span class="font-mono text-base font-semibold text-amber-900 dark:text-amber-100">
{insight.metadata["ap_count"]}
</span>
</div>
</div>
<%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %>
<div class="mt-3">
<p class="text-xs font-medium text-amber-800 dark:text-amber-300">
{t("Conflicting APs")}
</p>
<ul class="mt-1 space-y-1">
<li
:for={ap <- insight.metadata["aps"]}
class="flex items-center gap-2 text-xs text-amber-900 dark:text-amber-100"
>
<%= if ap["device_id"] do %>
<.link
navigate={~p"/devices/#{ap["device_id"]}"}
class="underline hover:no-underline"
>
{ap["name"] || "Unnamed"}
</.link>
<% else %>
<span>{ap["name"] || "Unnamed"}</span>
<% end %>
<%= if ap["channel_width_mhz"] do %>
<span class="text-amber-700 dark:text-amber-300">
({ap["channel_width_mhz"]} MHz wide)
</span>
<% end %>
</li>
</ul>
</div>
<% end %>
</div>
<% end %>
<%= if insight.type == "ap_frequency_change" do %>
<div class="mt-3 rounded-md border border-purple-200 bg-purple-50 p-3 dark:border-purple-800 dark:bg-purple-900/20">
<div class="flex items-center gap-3 text-sm">

View file

@ -1,4 +1,5 @@
2026-05-09 — Smarter recommendations + AI-generated insight summaries
* New recommendation: same-channel conflicts within your own network — when two or more of your APs at the same site sit on the same channel, the system flags the conflict with a clickable list so you can pick which one to retune
* New recommendation: sector overload detection — APs with low free airtime and active subscribers get flagged with subscriber count, QoE score, and AP model so you can decide whether to split the sector or upgrade the radio
* New recommendation: CPE re-aim candidates — wireless clients with both weak signal and low SNR (the alignment-issue signature) get individual cards with signal, SNR, TX/RX rate, and distance, so each truck-roll candidate is actionable on its own
* 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

View file

@ -0,0 +1,104 @@
defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflictTest do
@moduledoc """
Tests the rule that detects when multiple of our own APs at the same
site are configured on the same channel a self-interference pattern
that's visible without any neighbor-scan data.
"""
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Recommendations.Rules.OwnFleetChannelConflict
alias Towerops.Snmp.Device, as: SnmpDevice
setup do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, site} = Towerops.Sites.create_site(%{name: "Tower-1", organization_id: org.id})
%{org: org, site: site}
end
defp insert_ap(org, site, name, channel, freq) do
device = device_fixture(%{organization_id: org.id, site_id: site && site.id, name: name})
{:ok, _} =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: name,
current_channel: channel,
current_frequency_mhz: freq
})
|> Repo.insert()
device
end
test "no insight when all APs at site are on different channels",
%{org: org, site: site} do
insert_ap(org, site, "AP-A", 149, 5745)
insert_ap(org, site, "AP-B", 153, 5765)
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
test "no insight when only one AP is on a channel", %{org: org, site: site} do
insert_ap(org, site, "AP-A", 149, 5745)
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
test "emits one insight per (site, channel) when 2+ APs share a channel",
%{org: org, site: site} do
a = insert_ap(org, site, "AP-A", 149, 5745)
b = insert_ap(org, site, "AP-B", 149, 5745)
_c = insert_ap(org, site, "AP-C", 161, 5805)
assert insights = OwnFleetChannelConflict.evaluate(org.id)
assert length(insights) == 1
[insight] = insights
assert insight.type == "own_fleet_channel_conflict"
assert insight.site_id == site.id
assert insight.metadata["channel"] == 149
assert insight.metadata["frequency_mhz"] == 5745
assert insight.metadata["ap_count"] == 2
ap_ids = Enum.map(insight.metadata["aps"], & &1["device_id"])
assert a.id in ap_ids
assert b.id in ap_ids
end
test "ignores APs without a current_channel", %{org: org, site: site} do
insert_ap(org, site, "AP-A", nil, nil)
insert_ap(org, site, "AP-B", 149, 5745)
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
test "scopes to organization", %{org: org, site: site} do
insert_ap(org, site, "AP-A", 149, 5745)
insert_ap(org, site, "AP-B", 149, 5745)
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
assert OwnFleetChannelConflict.evaluate(other_org.id) == []
end
test "does not group APs across different sites", %{org: org, site: site} do
{:ok, site2} = Towerops.Sites.create_site(%{name: "Tower-2", organization_id: org.id})
insert_ap(org, site, "AP-A", 149, 5745)
insert_ap(org, site2, "AP-B", 149, 5745)
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
test "ignores APs with no site assigned", %{org: org} do
insert_ap(org, nil, "AP-Floating-A", 149, 5745)
insert_ap(org, nil, "AP-Floating-B", 149, 5745)
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
end

View file

@ -0,0 +1,125 @@
defmodule Towerops.Snmp.RadioStateTest do
@moduledoc """
Tests for the RadioState helper that extracts an AP's current channel
and frequency from the existing per-vendor `frequency` sensors and
persists them to the snmp_device row, so frequency-aware rules can
query a single canonical source.
"""
use Towerops.DataCase, async: true
import Ecto.Query
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.RadioState
alias Towerops.Snmp.Sensor
describe "frequency_to_channel/1" do
test "5 GHz UNII channels" do
assert RadioState.frequency_to_channel(5180) == 36
assert RadioState.frequency_to_channel(5200) == 40
assert RadioState.frequency_to_channel(5745) == 149
assert RadioState.frequency_to_channel(5805) == 161
assert RadioState.frequency_to_channel(5825) == 165
end
test "2.4 GHz channels" do
assert RadioState.frequency_to_channel(2412) == 1
assert RadioState.frequency_to_channel(2437) == 6
assert RadioState.frequency_to_channel(2462) == 11
assert RadioState.frequency_to_channel(2484) == 14
end
test "6 GHz UNII-5/6 channels" do
# 6 GHz channel 1 starts at 5955 MHz
assert RadioState.frequency_to_channel(5955) == 1
assert RadioState.frequency_to_channel(5975) == 5
end
test "returns nil for unknown / invalid frequencies" do
assert RadioState.frequency_to_channel(0) == nil
assert RadioState.frequency_to_channel(nil) == nil
assert RadioState.frequency_to_channel("not a number") == nil
assert RadioState.frequency_to_channel(900) == nil
end
end
describe "update_from_sensors/2" do
setup do
user = user_fixture()
org = organization_fixture(user.id)
device = device_fixture(%{organization_id: org.id, name: "AP-Test"})
{:ok, snmp} =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "AP-Test"})
|> Repo.insert()
%{snmp: snmp}
end
defp insert_sensor(snmp, attrs) do
full =
Map.merge(
%{
snmp_device_id: snmp.id,
sensor_type: "frequency",
sensor_index: "freq_#{System.unique_integer([:positive])}",
sensor_oid: "1.2.3.4.#{System.unique_integer([:positive])}",
sensor_unit: "MHz",
sensor_divisor: 1
},
attrs
)
{:ok, sensor} = %Sensor{} |> Sensor.changeset(full) |> Repo.insert()
sensor
end
test "no-ops when there are no frequency sensors", %{snmp: snmp} do
insert_sensor(snmp, %{sensor_type: "temperature", last_value: 45.0})
assert {:ok, updated} = RadioState.update_from_sensors(snmp)
assert is_nil(updated.current_frequency_mhz)
assert is_nil(updated.current_channel)
end
test "updates current_frequency_mhz and derives current_channel", %{snmp: snmp} do
insert_sensor(snmp, %{last_value: 5745.0})
assert {:ok, updated} = RadioState.update_from_sensors(snmp)
assert updated.current_frequency_mhz == 5745
assert updated.current_channel == 149
assert updated.last_radio_seen_at
end
test "picks the most recently updated frequency when several exist", %{snmp: snmp} do
old_sensor =
insert_sensor(snmp, %{last_value: 2437.0, sensor_index: "old_freq"})
_new_sensor =
insert_sensor(snmp, %{last_value: 5180.0, sensor_index: "new_freq"})
# Backdate the older sensor's updated_at
Repo.update_all(
from(s in Sensor, where: s.id == ^old_sensor.id),
set: [updated_at: ~U[2025-01-01 00:00:00Z]]
)
assert {:ok, updated} = RadioState.update_from_sensors(snmp)
assert updated.current_frequency_mhz == 5180
assert updated.current_channel == 36
end
test "ignores frequency sensors with nil or zero values", %{snmp: snmp} do
insert_sensor(snmp, %{last_value: nil, sensor_index: "nil_freq"})
insert_sensor(snmp, %{last_value: 0.0, sensor_index: "zero_freq"})
assert {:ok, updated} = RadioState.update_from_sensors(snmp)
assert is_nil(updated.current_frequency_mhz)
assert is_nil(updated.current_channel)
end
end
end