diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 98177c61..bf1a9306 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 8e5cc045..bb02e197 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 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) diff --git a/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex new file mode 100644 index 00000000..542a7b25 --- /dev/null +++ b/lib/towerops/recommendations/rules/own_fleet_channel_conflict.ex @@ -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 diff --git a/lib/towerops/snmp/radio_state.ex b/lib/towerops/snmp/radio_state.ex new file mode 100644 index 00000000..6b05dbed --- /dev/null +++ b/lib/towerops/snmp/radio_state.ex @@ -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 diff --git a/lib/towerops/workers/device_poller_worker.ex b/lib/towerops/workers/device_poller_worker.ex index b4f14286..36abc09d 100644 --- a/lib/towerops/workers/device_poller_worker.ex +++ b/lib/towerops/workers/device_poller_worker.ex @@ -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, diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex index cc55e51c..85bf8b24 100644 --- a/lib/towerops/workers/recommendations_run_worker.ex +++ b/lib/towerops/workers/recommendations_run_worker.ex @@ -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 diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 43a4bdf3..87c7c40c 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -480,6 +480,63 @@ <% end %> + <%= if insight.type == "own_fleet_channel_conflict" do %> +
+
+
+ + {t("Channel")} + + + {insight.metadata["channel"]} + <%= if insight.metadata["frequency_mhz"] do %> + + ({insight.metadata["frequency_mhz"]} MHz) + + <% end %> + +
+
+ + {t("APs sharing")} + + + {insight.metadata["ap_count"]} + +
+
+ <%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %> +
+

+ {t("Conflicting APs")} +

+ +
+ <% end %> +
+ <% end %> + <%= if insight.type == "ap_frequency_change" do %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 7201f486..7d2a9f41 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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 diff --git a/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs b/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs new file mode 100644 index 00000000..cc8e66be --- /dev/null +++ b/test/towerops/recommendations/rules/own_fleet_channel_conflict_test.exs @@ -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 diff --git a/test/towerops/snmp/radio_state_test.exs b/test/towerops/snmp/radio_state_test.exs new file mode 100644 index 00000000..692bbc30 --- /dev/null +++ b/test/towerops/snmp/radio_state_test.exs @@ -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