Powered by AppSignal & Oban Pro

Device Simulation

livebooks/04_device_simulation.livemd

Device Simulation

Build realistic SNMP device simulations for testing and development.

Setup

Mix.install([
  {:snmpkit, "~> 2.0"}
])

alias SnmpKit.{SNMP, Sim}
alias SnmpKit.SnmpSim.ProfileLoader

IO.puts("Simulation modules ready!")

Why Simulate?

Device simulation lets you:

  • Test SNMP code without real hardware
  • Create reproducible test scenarios
  • Simulate edge cases and error conditions
  • Develop offline

Profile Sources

SnmpKit supports multiple ways to define device profiles:

Source Use Case
{:manual, map} Quick testing, simple devices
{:walk_file, path} Real device snapshots
{:oid_walk, path} Numeric OID dumps
{:json_profile, path} Structured profiles

Manual Profiles

Define OIDs directly in code - great for testing.

Simple Device

# Minimal device with just system info
simple_oids = %{
  "1.3.6.1.2.1.1.1.0" => "Simple Test Device",
  "1.3.6.1.2.1.1.3.0" => %{type: "TimeTicks", value: 12345},
  "1.3.6.1.2.1.1.5.0" => "test-001"
}

{:ok, profile} = ProfileLoader.load_profile(:simple, {:manual, simple_oids})
{:ok, _device} = Sim.start_device(profile, port: 2001)

# Test it
{:ok, result} = SNMP.get("127.0.0.1:2001", "sysDescr.0")
IO.puts("Simple device: #{result.formatted}")

Type Inference

SnmpKit infers types from plain values; wrap a value in %{type:, value:} to choose one. sysUpTime.0 is served from the device's own clock, so its entry sets only the type:

auto_typed_oids = %{
  # Strings (binary) -> STRING
  "1.3.6.1.2.1.1.1.0" => "Device Description",

  # Integers -> INTEGER
  "1.3.6.1.2.1.2.1.0" => 4,

  # Explicit types with maps
  "1.3.6.1.2.1.1.3.0" => %{type: "TimeTicks", value: 100000},
  "1.3.6.1.2.1.2.2.1.10.1" => %{type: "Counter32", value: 1234567},
  "1.3.6.1.2.1.2.2.1.5.1" => %{type: "Gauge32", value: 1000000000}
}

{:ok, profile} = ProfileLoader.load_profile(:typed, {:manual, auto_typed_oids})
{:ok, _device} = Sim.start_device(profile, port: 2002)

{:ok, result} = SNMP.get("127.0.0.1:2002", "sysUpTime.0")
IO.puts("Uptime type: #{result.type}, value: #{result.value}")

Building Realistic Devices

Cable Modem

cable_modem = %{
  # System Group
  "1.3.6.1.2.1.1.1.0" => "ARRIS SURFboard SB8200 DOCSIS 3.1 Cable Modem",
  "1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.4115.1.20.1",
  "1.3.6.1.2.1.1.3.0" => %{type: "TimeTicks", value: 8640000},  # 1 day
  "1.3.6.1.2.1.1.4.0" => "support@isp.com",
  "1.3.6.1.2.1.1.5.0" => "CM-MAC-001",
  "1.3.6.1.2.1.1.6.0" => "Customer Premises",

  # Interfaces
  "1.3.6.1.2.1.2.1.0" => 2,
  "1.3.6.1.2.1.2.2.1.1.1" => 1,
  "1.3.6.1.2.1.2.2.1.1.2" => 2,
  "1.3.6.1.2.1.2.2.1.2.1" => "downstream0",
  "1.3.6.1.2.1.2.2.1.2.2" => "upstream0",
  "1.3.6.1.2.1.2.2.1.3.1" => 127,
  "1.3.6.1.2.1.2.2.1.3.2" => 129,
  "1.3.6.1.2.1.2.2.1.5.1" => %{type: "Gauge32", value: 1000000000},
  "1.3.6.1.2.1.2.2.1.5.2" => %{type: "Gauge32", value: 30000000},
  "1.3.6.1.2.1.2.2.1.8.1" => 1,  # up
  "1.3.6.1.2.1.2.2.1.8.2" => 1,

  # Traffic counters
  "1.3.6.1.2.1.2.2.1.10.1" => %{type: "Counter32", value: 98765432},
  "1.3.6.1.2.1.2.2.1.16.1" => %{type: "Counter32", value: 12345678}
}

{:ok, cm_profile} = ProfileLoader.load_profile(:cable_modem, {:manual, cable_modem})
{:ok, _cm} = Sim.start_device(cm_profile, port: 2003)

IO.puts("Cable modem on port 2003")

# Query it
{:ok, results} = SNMP.walk("127.0.0.1:2003", "system")
IO.puts("System info (#{length(results)} objects)")

Multi-Port Switch

Build devices programmatically:

# 24-port switch
num_ports = 24

switch_oids = %{
  "1.3.6.1.2.1.1.1.0" => "Cisco Catalyst 2960-24TT-L",
  "1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.716",
  "1.3.6.1.2.1.1.3.0" => %{type: "TimeTicks", value: 432000000},  # 50 days
  "1.3.6.1.2.1.1.4.0" => "netops@company.com",
  "1.3.6.1.2.1.1.5.0" => "switch-floor2",
  "1.3.6.1.2.1.1.6.0" => "Building A, Floor 2, Closet 3",
  "1.3.6.1.2.1.2.1.0" => num_ports
}

# Add interfaces programmatically
switch_oids = Enum.reduce(1..num_ports, switch_oids, fn port, acc ->
  # Simulate some ports up, some down
  status = if rem(port, 5) == 0, do: 2, else: 1  # Every 5th port is down

  Map.merge(acc, %{
    "1.3.6.1.2.1.2.2.1.1.#{port}" => port,
    "1.3.6.1.2.1.2.2.1.2.#{port}" => "FastEthernet0/#{port}",
    "1.3.6.1.2.1.2.2.1.3.#{port}" => 6,  # ethernetCsmacd
    "1.3.6.1.2.1.2.2.1.5.#{port}" => %{type: "Gauge32", value: 100_000_000},
    "1.3.6.1.2.1.2.2.1.8.#{port}" => status,
    "1.3.6.1.2.1.2.2.1.10.#{port}" => %{type: "Counter32", value: :rand.uniform(1_000_000_000)},
    "1.3.6.1.2.1.2.2.1.16.#{port}" => %{type: "Counter32", value: :rand.uniform(500_000_000)}
  })
end)

{:ok, switch_profile} = ProfileLoader.load_profile(:switch, {:manual, switch_oids})
{:ok, _switch} = Sim.start_device(switch_profile, port: 2004)

IO.puts("24-port switch on port 2004")

# Count interfaces
{:ok, if_count} = SNMP.get("127.0.0.1:2004", "ifNumber.0")
IO.puts("Interface count: #{if_count.value}")

# Walk interface descriptions
{:ok, if_descr} = SNMP.walk("127.0.0.1:2004", "ifDescr")
IO.puts("Interface descriptions: #{length(if_descr)} entries")

Device Behaviors

Behaviors make values move. Without behaviors: the loader infers them for walk-file profiles from the object names (traffic counters, packet counters, gauges); pass a SnmpKit.SnmpSim.BehaviorConfig preset, or your own {:increment_counters, config} / {:vary_gauges, config} list, to choose. A counter with a behavior grows with the device's uptime, so two polls a few seconds apart give SnmpKit.SNMP.Rate a real delta. Manual profiles serve fixed values unless you pass behaviors: too.

alias SnmpKit.SnmpSim.BehaviorConfig

{:ok, dynamic_profile} = ProfileLoader.load_profile(
  :cable_modem,
  {:walk_file, ProfileLoader.bundled_walk(:cable_modem)},
  behaviors: BehaviorConfig.get_preset(:cable_modem_realistic)
)

{:ok, _device} = Sim.start_device(dynamic_profile, port: 2005)

%{behavior: {kind, config}} = dynamic_profile.oid_map["1.3.6.1.2.1.2.2.1.10.1"]
IO.puts("Dynamic device on port 2005")
IO.puts("ifInOctets.1 behavior: #{kind}, rate range #{inspect(config.rate_range)}")

Multiple Devices

Start several devices for multi-target testing:

# Create 5 devices on consecutive ports
devices = for i <- 1..5 do
  oids = %{
    "1.3.6.1.2.1.1.1.0" => "Test Device #{i}",
    "1.3.6.1.2.1.1.3.0" => %{type: "TimeTicks", value: i * 100000},
    "1.3.6.1.2.1.1.5.0" => "device-#{String.pad_leading("#{i}", 3, "0")}"
  }

  port = 2010 + i
  {:ok, profile} = ProfileLoader.load_profile(:"device_#{i}", {:manual, oids})
  {:ok, device} = Sim.start_device(profile, port: port)

  {port, device}
end

IO.puts("Started #{length(devices)} devices on ports 2011-2015")

# Query all of them
targets = Enum.map(2011..2015, &{"127.0.0.1:#{&1}", "sysName.0"})
results = SNMP.get_multi(targets)

IO.puts("\nDevice names:")
Enum.zip(2011..2015, results)
|> Enum.each(fn {port, result} ->
  case result do
    {:ok, [%{formatted: name} | _]} -> IO.puts("  Port #{port}: #{name}")
    {:error, reason} -> IO.puts("  Port #{port}: Error - #{inspect(reason)}")
  end
end)

Device Populations

For larger fleets, start many devices in one call. Explicit maps pick a bundled profile per device (:cable_modem, :router, :switch):

{:ok, devices} =
  Sim.start_device_population([
    %{type: :router, port: 2021},
    %{type: :switch, port: 2022},
    %{type: :cable_modem, port: 2023, community: "public"}
  ])

Enum.each(devices, fn %{type: type, target: target} ->
  {:ok, %{formatted: descr}} = SNMP.get(target, "sysDescr.0")
  IO.puts("#{type} on #{target}: #{descr}")
end)

Counted groups take a profile source and a port range:

{:ok, fleet} =
  Sim.start_device_population(
    [{:cable_modem, {:walk_file, ProfileLoader.bundled_walk(:cable_modem)}, count: 10}],
    port_range: 2100..2109
  )

IO.puts("Started #{length(fleet)} cable modems on ports 2100-2109")

Population devices run under the simulator's supervisor; SnmpKit.SnmpSim.list_devices/0 shows them and SnmpKit.SnmpSim.stop/0 removes them.

Next Steps