When a wireless AP has a strong same-channel neighbor (RSSI >= -75 dBm) and a measurably cleaner alternative channel exists in the same band, the system now emits an ap_frequency_change insight with a specific recommended channel and the top interfering BSSIDs as evidence. - New schema wireless_neighbor_scans (append-only) storing observed neighboring APs per device: bssid, ssid, channel, frequency_mhz, channel_width_mhz, rssi_dbm, is_own_fleet, seen_at - New columns on snmp_devices: current_channel, current_frequency_mhz, current_channel_width_mhz, last_radio_seen_at — populated by vendor SNMP profiles for APs that expose wireless OIDs - Towerops.Recommendations.Rules.FrequencyChange — pure rule that scores each candidate channel using sum-of-linear-power over the last 24h of neighbor scans, recommends the cleanest if it beats the current by at least 6 dB. Critical urgency when current score >= 100. - Towerops.Workers.RecommendationsRunWorker — hourly Oban cron, iterates organizations and runs all rules, uses insert_insight_if_new/1 for idempotent upsert - LiveView /insights renders a purple frequency-change card with current vs recommended channel, dB-cleaner badge, and a top-offender list (BSSID, SSID, RSSI, own-fleet flag) - LLM enrichment automatically applies to the new insight type Also fixes two pre-existing flaky tests that surfaced in CI: - test/towerops_web/live/agent_live_test.exs:567 — now sets an explicit Req.Test transport_error stub for ReleaseChecker and uses Req.Test.allow so the LiveView process inherits it. Previously relied on absence of a stub, which leaked across tests and produced different behavior under full-suite parallel runs. - test/towerops_web/telemetry_test.exs:140 — relaxes the "no log" check to refute the specific messages the function under test would emit, rather than asserting an empty capture_log/1 buffer (which catches stray logs from concurrent tests, e.g. Postgrex sandbox disconnects).
225 lines
7.2 KiB
Elixir
225 lines
7.2 KiB
Elixir
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
|