From edb330e4de094647264bde7de530676367bedb19 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 11 Feb 2026 15:33:08 -0600 Subject: [PATCH] normalize OIDs to fix agent-polled sensor matching strip leading dots from OID keys returned by agent so they match sensor OIDs stored without leading dots. fixes ubiquiti airos wireless sensors (clients, ccq, frequency, etc) not being polled. also fix flaky sensor_change_detector tests using global pubsub topic. --- CHANGELOG.txt | 12 ++++++++++++ lib/towerops_web/channels/agent_channel.ex | 16 ++++++++++++++-- .../snmp/sensor_change_detector_test.exs | 13 ++++++------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 5d256735..130430d1 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,6 +1,18 @@ CHANGELOG - towerops-web ======================== +2026-02-11 - fix: normalize OIDs so agent-polled sensors match regardless of leading dot + - File: lib/towerops_web/channels/agent_channel.ex + Agent (gosnmp) returns OIDs with leading dots (e.g. ".1.3.6.1.4.1.41112...") + but sensor OIDs stored in DB may omit the dot. process_sensor_reading used + direct Map.get which silently missed wireless/vendor sensors on Ubiquiti airOS. + - Fix: Added normalize_oid_keys/1 to strip leading dots from all oid_values keys, + and normalize sensor.sensor_oid too. Both sides now match without leading dot. + - File: test/towerops/snmp/sensor_change_detector_test.exs + Fixed flaky refute_receive tests that subscribed to global "device:events" topic + and caught unrelated device_discovered events from concurrent async tests. + Changed to device-specific topic "device:#{device.id}" instead. + 2026-02-11 - feat: add sensor value change events for all sensor types - New file: lib/towerops/snmp/sensor_change_detector.ex Extracted shared sensor change detection logic from DevicePollerWorker. diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 80670fcd..28473678 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -1377,7 +1377,7 @@ defmodule ToweropsWeb.AgentChannel do Devices.update_snmp_poll_time(device) snmp_device = device.snmp_device - oid_values = Map.new(result.oid_values) + oid_values = normalize_oid_keys(result.oid_values) # Use server time to avoid clock drift issues between agents timestamp = DateTime.utc_now() @@ -1534,7 +1534,9 @@ defmodule ToweropsWeb.AgentChannel do end defp process_sensor_reading(sensor, oid_values, timestamp) do - case Map.get(oid_values, sensor.sensor_oid) do + normalized_oid = String.trim_leading(sensor.sensor_oid, ".") + + case Map.get(oid_values, normalized_oid) do nil -> :ok @@ -1586,6 +1588,16 @@ defmodule ToweropsWeb.AgentChannel do defp parse_integer(_), do: nil + # Normalize OID keys to strip leading dots. + # The agent (via gosnmp/net-snmp) returns OIDs with leading dots + # (e.g., ".1.3.6.1.2.1.1.1.0") but sensor OIDs and hardcoded interface + # OIDs use the format without (e.g., "1.3.6.1.2.1.1.1.0"). + defp normalize_oid_keys(oid_values) do + Map.new(oid_values, fn {oid, value} -> + {String.trim_leading(oid, "."), value} + end) + end + defp parse_float(value) when is_float(value), do: value defp parse_float(value) when is_integer(value), do: value / 1.0 diff --git a/test/towerops/snmp/sensor_change_detector_test.exs b/test/towerops/snmp/sensor_change_detector_test.exs index dbb183a2..aef9f3e6 100644 --- a/test/towerops/snmp/sensor_change_detector_test.exs +++ b/test/towerops/snmp/sensor_change_detector_test.exs @@ -106,23 +106,22 @@ defmodule Towerops.Snmp.SensorChangeDetectorTest do assert String.contains?(event.message, "46.0C") end - test "does not emit event when value is unchanged", %{snmp_device: snmp_device} do + test "does not emit event when value is unchanged", %{device: device, snmp_device: snmp_device} do sensor = create_sensor(snmp_device, %{last_value: 50.0, sensor_unit: "Hz"}) timestamp = DateTime.utc_now() - # Subscribe to both topics - Phoenix.PubSub.subscribe(Towerops.PubSub, "device:events") + Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}") SensorChangeDetector.detect_and_broadcast(sensor, 50.0, timestamp) refute_receive {:device_event, _}, 100 end - test "does not emit sensor_value_changed when last_value is nil", %{snmp_device: snmp_device} do + test "does not emit sensor_value_changed when last_value is nil", %{device: device, snmp_device: snmp_device} do sensor = create_sensor(snmp_device, %{last_value: nil, sensor_unit: "Hz"}) timestamp = DateTime.utc_now() - Phoenix.PubSub.subscribe(Towerops.PubSub, "device:events") + Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}") SensorChangeDetector.detect_and_broadcast(sensor, 50.0, timestamp) @@ -177,7 +176,7 @@ defmodule Towerops.Snmp.SensorChangeDetectorTest do assert event.severity == "info" end - test "does not emit spike/drop for % sensor with <30% change", %{snmp_device: snmp_device} do + test "does not emit spike/drop for % sensor with <30% change", %{device: device, snmp_device: snmp_device} do sensor = create_sensor(snmp_device, %{ sensor_type: "percent", @@ -189,7 +188,7 @@ defmodule Towerops.Snmp.SensorChangeDetectorTest do timestamp = DateTime.utc_now() - Phoenix.PubSub.subscribe(Towerops.PubSub, "device:events") + Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}") SensorChangeDetector.detect_and_broadcast(sensor, 55.0, timestamp)