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.
This commit is contained in:
Graham McIntire 2026-02-11 15:33:08 -06:00
parent abbe117f5e
commit edb330e4de
No known key found for this signature in database
3 changed files with 32 additions and 9 deletions

View file

@ -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.

View file

@ -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

View file

@ -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)