add state sensor schema, migration, and discovery

This commit is contained in:
Graham McIntire 2026-01-21 10:25:01 -06:00
parent 7c660169b7
commit c3e26a44d6
No known key found for this signature in database
6 changed files with 670 additions and 1 deletions

3
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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