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("Conflicting APs")} +
+