From c3e26a44d66691cef4e18d873ce49c80c1193e0d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 21 Jan 2026 10:25:01 -0600 Subject: [PATCH] add state sensor schema, migration, and discovery --- .gitignore | 3 +- lib/towerops/snmp/profiles/base.ex | 143 ++++++++++ lib/towerops/snmp/state_sensor.ex | 79 ++++++ ...260121161424_create_snmp_state_sensors.exs | 29 +++ test/towerops/snmp/profiles/base_test.exs | 171 ++++++++++++ test/towerops/snmp/state_sensor_test.exs | 246 ++++++++++++++++++ 6 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 lib/towerops/snmp/state_sensor.ex create mode 100644 priv/repo/migrations/20260121161424_create_snmp_state_sensors.exs create mode 100644 test/towerops/snmp/state_sensor_test.exs diff --git a/.gitignore b/.gitignore index ecc7f6a4..0d207ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -45,7 +45,7 @@ k8s/.envrc /towerops-agent/ # TODO tracking (personal notes, not for version control) -TODOS.md +TODO.md # SNMP profiles export (temporary/local data) profiles.json @@ -60,3 +60,4 @@ profiles.json # Application MIB files (now committed to git and included in Docker image) # Only ignore the top-level librenms mibs directory (symlink) + diff --git a/lib/towerops/snmp/profiles/base.ex b/lib/towerops/snmp/profiles/base.ex index b6997cb1..c10cd49a 100644 --- a/lib/towerops/snmp/profiles/base.ex +++ b/lib/towerops/snmp/profiles/base.ex @@ -66,6 +66,15 @@ defmodule Towerops.Snmp.Profiles.Base do ent_phys_model_name: "1.3.6.1.2.1.47.1.1.1.1.13" } + @entity_state_oids %{ + # ENTITY-STATE-MIB - Operational state monitoring + ent_state_oper: "1.3.6.1.2.1.131.1.1.1.1" + } + + # ENTITY-MIB physical class values to track for state sensors + # 6 = powerSupply, 7 = fan + @state_sensor_classes [6, 7] + @doc """ Discovers system information from SNMPv2-MIB. Returns a map with device metadata. @@ -196,6 +205,63 @@ defmodule Towerops.Snmp.Profiles.Base do end) end + @doc """ + Discovers state sensors from ENTITY-MIB and ENTITY-STATE-MIB. + State sensors track discrete status values for power supplies, fans, and modules. + Returns a list of state sensor maps. + """ + @spec discover_state_sensors(Client.connection_opts()) :: {:ok, [map()]} + def discover_state_sensors(client_opts) do + # Walk entity physical class to find entities with state + # Client.walk returns {:ok, %{oid => value}} map + case Client.walk(client_opts, @entity_mib_oids.ent_phys_class) do + {:ok, class_results} when is_map(class_results) and map_size(class_results) > 0 -> + do_discover_state_sensors(client_opts, class_results) + + {:ok, _} -> + Logger.debug("No ENTITY-MIB physical entities found") + {:ok, []} + + {:error, reason} -> + Logger.debug("ENTITY-MIB walk failed: #{inspect(reason)}") + {:ok, []} + end + end + + defp do_discover_state_sensors(client_opts, class_results) do + # Build a map of index => class (Client.walk already extracts values) + class_map = build_index_value_map(class_results) + + # Filter to only power supplies and fans + state_indices = + class_map + |> Enum.filter(fn {_index, class} -> class in @state_sensor_classes end) + |> Enum.map(fn {index, _class} -> index end) + + if Enum.empty?(state_indices) do + Logger.debug("No power supply or fan entities found") + {:ok, []} + else + build_state_sensors_from_indices(client_opts, state_indices, class_map) + end + end + + defp build_state_sensors_from_indices(client_opts, state_indices, class_map) do + # Fetch descriptions and operational states + descr_map = fetch_entity_descr_map(client_opts) + state_map = fetch_entity_state_map(client_opts) + + state_sensors = + state_indices + |> Enum.map(fn index -> + build_state_sensor(index, class_map, descr_map, state_map) + end) + |> Enum.reject(&is_nil/1) + + Logger.debug("Discovered #{length(state_sensors)} state sensors") + {:ok, state_sensors} + end + @doc """ Identifies device manufacturer and model from sysDescr and sysObjectID. Can be overridden by vendor-specific profiles. @@ -442,6 +508,83 @@ defmodule Towerops.Snmp.Profiles.Base do defp parse_sensor_status(3), do: "nonoperational" defp parse_sensor_status(_), do: "unknown" + # State sensor helper functions + + # Build a map of index => value from Client.walk results (already a Map) + # Client.walk returns %{"full.oid.string" => extracted_value} + defp build_index_value_map(walk_map) when is_map(walk_map) do + Map.new(walk_map, fn {oid, value} -> + index = extract_index_from_oid(oid) + {index, value} + end) + end + + defp build_index_value_map(_), do: %{} + + defp extract_index_from_oid(oid) when is_binary(oid) do + oid + |> String.split(".") + |> List.last() + end + + defp fetch_entity_descr_map(client_opts) do + case Client.walk(client_opts, @entity_mib_oids.ent_phys_descr) do + {:ok, results} when is_map(results) -> build_index_value_map(results) + _ -> %{} + end + end + + defp fetch_entity_state_map(client_opts) do + case Client.walk(client_opts, @entity_state_oids.ent_state_oper) do + {:ok, results} when is_map(results) -> build_index_value_map(results) + _ -> %{} + end + end + + defp build_state_sensor(index, class_map, descr_map, state_map) do + class = Map.get(class_map, index) + descr = Map.get(descr_map, index, "Entity #{index}") + state_value = Map.get(state_map, index) + + entity_type = physical_class_to_type(class) + status = entity_state_to_status(state_value) + + %{ + sensor_index: index, + sensor_oid: @entity_state_oids.ent_state_oper <> ".#{index}", + sensor_descr: descr, + entity_type: entity_type, + state_value: state_value, + state_descr: entity_state_to_descr(state_value), + status: status, + last_checked_at: DateTime.utc_now() + } + end + + # ENTITY-MIB physical class to type mapping + defp physical_class_to_type(6), do: "power_supply" + defp physical_class_to_type(7), do: "fan" + defp physical_class_to_type(9), do: "module" + defp physical_class_to_type(3), do: "chassis" + defp physical_class_to_type(5), do: "container" + defp physical_class_to_type(8), do: "sensor" + defp physical_class_to_type(_), do: "unknown" + + # ENTITY-STATE-MIB operational status mapping + # 1=unknown, 2=disabled, 3=enabled, 4=testing + # We map to: ok, warning, critical, unknown + defp entity_state_to_status(1), do: "unknown" + defp entity_state_to_status(2), do: "ok" + defp entity_state_to_status(3), do: "warning" + defp entity_state_to_status(4), do: "unknown" + defp entity_state_to_status(_), do: "unknown" + + defp entity_state_to_descr(1), do: "unknown" + defp entity_state_to_descr(2), do: "enabled" + defp entity_state_to_descr(3), do: "disabled" + defp entity_state_to_descr(4), do: "testing" + defp entity_state_to_descr(_), do: "unknown" + @doc """ Collects raw debug data for troubleshooting. Includes system OIDs and interface/sensor tables. diff --git a/lib/towerops/snmp/state_sensor.ex b/lib/towerops/snmp/state_sensor.ex new file mode 100644 index 00000000..4aaa0830 --- /dev/null +++ b/lib/towerops/snmp/state_sensor.ex @@ -0,0 +1,79 @@ +defmodule Towerops.Snmp.StateSensor do + @moduledoc """ + SNMP state sensor schema for monitoring device health status. + + Unlike numeric sensors that track values like temperature or voltage, + state sensors track discrete states like power supply status, fan status, + and module operational status. + + State values are mapped to generic status categories: + - ok: Normal operation + - warning: Degraded but functional + - critical: Failed or about to fail + - unknown: State not determined + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Ecto.Association.NotLoaded + alias Towerops.Snmp.Device + + @valid_statuses ~w(ok warning critical unknown) + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "snmp_state_sensors" do + field :sensor_index, :string + field :sensor_oid, :string + field :sensor_descr, :string + field :entity_type, :string + field :state_value, :integer + field :state_descr, :string + field :status, :string, default: "unknown" + field :last_checked_at, :utc_datetime + field :metadata, :map, default: %{} + + belongs_to :snmp_device, Device + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + sensor_index: String.t(), + sensor_oid: String.t(), + sensor_descr: String.t() | nil, + entity_type: String.t() | nil, + state_value: integer() | nil, + state_descr: String.t() | nil, + status: String.t(), + last_checked_at: DateTime.t() | nil, + metadata: map(), + snmp_device_id: Ecto.UUID.t(), + snmp_device: NotLoaded.t() | Device.t(), + inserted_at: DateTime.t(), + updated_at: DateTime.t() + } + + @doc false + def changeset(state_sensor, attrs) do + state_sensor + |> cast(attrs, [ + :snmp_device_id, + :sensor_index, + :sensor_oid, + :sensor_descr, + :entity_type, + :state_value, + :state_descr, + :status, + :last_checked_at, + :metadata + ]) + |> validate_required([:snmp_device_id, :sensor_index, :sensor_oid]) + |> validate_inclusion(:status, @valid_statuses) + |> unique_constraint([:snmp_device_id, :sensor_index]) + |> foreign_key_constraint(:snmp_device_id) + end +end diff --git a/priv/repo/migrations/20260121161424_create_snmp_state_sensors.exs b/priv/repo/migrations/20260121161424_create_snmp_state_sensors.exs new file mode 100644 index 00000000..feff88ea --- /dev/null +++ b/priv/repo/migrations/20260121161424_create_snmp_state_sensors.exs @@ -0,0 +1,29 @@ +defmodule Towerops.Repo.Migrations.CreateSnmpStateSensors do + use Ecto.Migration + + def change do + create table(:snmp_state_sensors, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :snmp_device_id, references(:snmp_devices, type: :binary_id, on_delete: :delete_all), + null: false + + add :sensor_index, :string, null: false + add :sensor_oid, :string, null: false + add :sensor_descr, :string + add :entity_type, :string + add :state_value, :integer + add :state_descr, :string + add :status, :string, default: "unknown" + add :last_checked_at, :utc_datetime + add :metadata, :map, default: %{} + + timestamps(type: :utc_datetime) + end + + create index(:snmp_state_sensors, [:snmp_device_id]) + create unique_index(:snmp_state_sensors, [:snmp_device_id, :sensor_index]) + create index(:snmp_state_sensors, [:status]) + create index(:snmp_state_sensors, [:entity_type]) + end +end diff --git a/test/towerops/snmp/profiles/base_test.exs b/test/towerops/snmp/profiles/base_test.exs index 93a97df6..7d0034ac 100644 --- a/test/towerops/snmp/profiles/base_test.exs +++ b/test/towerops/snmp/profiles/base_test.exs @@ -377,4 +377,175 @@ defmodule Towerops.Snmp.Profiles.BaseTest do assert device_info.sys_location == "DC1" end end + + describe "discover_state_sensors/1" do + test "discovers state sensors from ENTITY-STATE-MIB" do + # Mock the walk for entity physical class to find modules/power supplies + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + # entPhysicalClass walk - find entities by class + "1.3.6.1.2.1.47.1.1.1.1.5" -> + {:ok, + [ + # powerSupply = 6, fan = 7, sensor = 8, module = 9 + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1001", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1002", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2001", value: {:integer, 7}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2002", value: {:integer, 7}} + ]} + + # entPhysicalDescr walk - descriptions + "1.3.6.1.2.1.47.1.1.1.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1001", value: {:octet_string, "Power Supply 1"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1002", value: {:octet_string, "Power Supply 2"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2001", value: {:octet_string, "Fan Tray 1"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2002", value: {:octet_string, "Fan Tray 2"}} + ]} + + # ENTITY-STATE-MIB operational status walk + "1.3.6.1.2.1.131.1.1.1.1" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.131.1.1.1.1.1001", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.1002", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.2001", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.2002", value: {:integer, 3}} + ]} + + _ -> + {:ok, []} + end + end) + + stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end) + + assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts) + + assert length(state_sensors) == 4 + + psu1 = Enum.find(state_sensors, &(&1.sensor_index == "1001")) + assert psu1.sensor_descr == "Power Supply 1" + assert psu1.entity_type == "power_supply" + assert psu1.state_value == 2 + assert psu1.status == "ok" + + fan2 = Enum.find(state_sensors, &(&1.sensor_index == "2002")) + assert fan2.sensor_descr == "Fan Tray 2" + assert fan2.entity_type == "fan" + assert fan2.state_value == 3 + assert fan2.status == "warning" + end + + test "returns empty list when ENTITY-STATE-MIB not supported" do + stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end) + + assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts) + assert state_sensors == [] + end + + test "handles walk errors gracefully" do + stub(SnmpMock, :walk, fn _, _, _ -> {:error, :timeout} end) + + assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts) + assert state_sensors == [] + end + + test "filters only power supplies and fans by default" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + "1.3.6.1.2.1.47.1.1.1.1.5" -> + {:ok, + [ + # chassis = 3, container = 5, powerSupply = 6, fan = 7 + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1", value: {:integer, 3}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.100", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.200", value: {:integer, 7}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.300", value: {:integer, 5}} + ]} + + "1.3.6.1.2.1.47.1.1.1.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "Chassis"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.100", value: {:octet_string, "PSU"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.200", value: {:octet_string, "Fan"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.300", value: {:octet_string, "Container"}} + ]} + + "1.3.6.1.2.1.131.1.1.1.1" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.131.1.1.1.1.1", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.100", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.200", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.300", value: {:integer, 2}} + ]} + + _ -> + {:ok, []} + end + end) + + stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end) + + assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts) + + # Should only include power supplies and fans (not chassis or container) + assert length(state_sensors) == 2 + entity_types = Enum.map(state_sensors, & &1.entity_type) + assert "power_supply" in entity_types + assert "fan" in entity_types + refute "chassis" in entity_types + refute "container" in entity_types + end + + test "maps ENTITY-STATE-MIB operational status to status correctly" do + stub(SnmpMock, :walk, fn _, oid, _ -> + case oid do + "1.3.6.1.2.1.47.1.1.1.1.5" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.1", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.2", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.3", value: {:integer, 6}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.5.4", value: {:integer, 6}} + ]} + + "1.3.6.1.2.1.47.1.1.1.1.2" -> + {:ok, + [ + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.1", value: {:octet_string, "PSU1"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.2", value: {:octet_string, "PSU2"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.3", value: {:octet_string, "PSU3"}}, + %{oid: "1.3.6.1.2.1.47.1.1.1.1.2.4", value: {:octet_string, "PSU4"}} + ]} + + "1.3.6.1.2.1.131.1.1.1.1" -> + {:ok, + [ + # 1=unknown, 2=enabled(ok), 3=disabled(warning), 4=testing(unknown) + %{oid: "1.3.6.1.2.1.131.1.1.1.1.1", value: {:integer, 1}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.2", value: {:integer, 2}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.3", value: {:integer, 3}}, + %{oid: "1.3.6.1.2.1.131.1.1.1.1.4", value: {:integer, 4}} + ]} + + _ -> + {:ok, []} + end + end) + + stub(SnmpMock, :get, fn _, _, _ -> {:error, :no_such_object} end) + + assert {:ok, state_sensors} = Base.discover_state_sensors(@client_opts) + + statuses = Map.new(state_sensors, &{&1.sensor_index, &1.status}) + assert statuses["1"] == "unknown" + assert statuses["2"] == "ok" + assert statuses["3"] == "warning" + assert statuses["4"] == "unknown" + end + end end diff --git a/test/towerops/snmp/state_sensor_test.exs b/test/towerops/snmp/state_sensor_test.exs new file mode 100644 index 00000000..3d94ceec --- /dev/null +++ b/test/towerops/snmp/state_sensor_test.exs @@ -0,0 +1,246 @@ +defmodule Towerops.Snmp.StateSensorTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + + alias Towerops.Snmp.Device + alias Towerops.Snmp.StateSensor + + setup do + user = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, device_schema} = + Towerops.Devices.create_device(%{ + name: "Test Router", + ip_address: "192.168.1.1", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: site.id + }) + + snmp_device = + %Device{} + |> Device.changeset(%{ + device_id: device_schema.id, + sys_name: "test-router", + sys_descr: "Test Router" + }) + |> Repo.insert!() + + %{device: device_schema, snmp_device: snmp_device} + end + + describe "changeset/2" do + test "valid changeset with all required fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid? + end + + test "valid changeset with all fields", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1", + sensor_descr: "Power Supply 1", + entity_type: "power_supply", + state_value: 1, + state_descr: "normal", + status: "ok", + last_checked_at: DateTime.utc_now(), + metadata: %{"vendor" => "cisco"} + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid? + assert get_field(changeset, :sensor_descr) == "Power Supply 1" + assert get_field(changeset, :entity_type) == "power_supply" + assert get_field(changeset, :state_value) == 1 + assert get_field(changeset, :state_descr) == "normal" + assert get_field(changeset, :status) == "ok" + end + + test "invalid changeset without snmp_device_id" do + attrs = %{ + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).snmp_device_id + end + + test "invalid changeset without sensor_index", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).sensor_index + end + + test "invalid changeset without sensor_oid", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).sensor_oid + end + + test "invalid status value", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1", + status: "invalid_status" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).status + end + + test "valid status values", %{snmp_device: snmp_device} do + for status <- ~w(ok warning critical unknown) do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.#{status}", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1", + status: status + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid?, "status '#{status}' should be valid" + end + end + + test "default status is unknown", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid? + # Default should come from schema, not changeset + end + + test "default metadata is empty map", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid? + end + end + + describe "unique constraint" do + test "prevents duplicate sensors with same device and index", %{snmp_device: snmp_device} do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + # Insert first sensor + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert {:ok, _sensor} = Repo.insert(changeset) + + # Attempt to insert duplicate + changeset2 = StateSensor.changeset(%StateSensor{}, attrs) + + assert {:error, changeset} = Repo.insert(changeset2) + errors = errors_on(changeset) + # The unique constraint may report on either field + assert "has already been taken" in Map.get(errors, :sensor_index, []) or + "has already been taken" in Map.get(errors, :snmp_device_id, []) + end + + test "allows same sensor_index for different devices", %{device: device_schema} do + # Create second device + {:ok, device2} = + Towerops.Devices.create_device(%{ + name: "Test Switch", + ip_address: "192.168.1.2", + snmp_enabled: true, + snmp_version: "2c", + snmp_community: "public", + snmp_port: 161, + site_id: device_schema.site_id + }) + + snmp_device2 = + %Device{} + |> Device.changeset(%{ + device_id: device2.id, + sys_name: "test-switch", + sys_descr: "Test Switch" + }) + |> Repo.insert!() + + snmp_device1 = Repo.get_by!(Device, device_id: device_schema.id) + + attrs1 = %{ + snmp_device_id: snmp_device1.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + attrs2 = %{ + snmp_device_id: snmp_device2.id, + sensor_index: "psu.1", + sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.5.1" + } + + changeset1 = StateSensor.changeset(%StateSensor{}, attrs1) + assert {:ok, _sensor1} = Repo.insert(changeset1) + + changeset2 = StateSensor.changeset(%StateSensor{}, attrs2) + assert {:ok, _sensor2} = Repo.insert(changeset2) + end + end + + describe "entity_type values" do + test "accepts common entity types", %{snmp_device: snmp_device} do + entity_types = ~w(power_supply fan module chassis sensor port) + + for {entity_type, i} <- Enum.with_index(entity_types) do + attrs = %{ + snmp_device_id: snmp_device.id, + sensor_index: "entity.#{i}", + sensor_oid: "1.3.6.1.2.1.131.1.1.1.#{i}", + entity_type: entity_type + } + + changeset = StateSensor.changeset(%StateSensor{}, attrs) + assert changeset.valid?, "entity_type '#{entity_type}' should be valid" + end + end + end +end