device-class-authoring · git:20261101.bd19e00 · 2026-11-01 · sha256 9864f807f9bbf337
device-class-authoring git:20261101.bd19e00A
Immutable. This exact content never changes and is served at /api/v1/blob/9864f807f9bbf337.
---
name: device-class-authoring
description: Use when adding or changing an SNMP::Info device class (lib/SNMP/Info/Layer*/Vendor.pm), its detection in device_type(), its tests or its POD, or when choosing between a standard MIB and a vendor MIB for a method.
---
# Writing SNMP::Info device classes
A class is judged by what the calling application can store from it.
Standard sources come first, vendor sources are fallbacks, and the class
reuses the patterns the library already has. Read `AGENTS.md` first; it
holds the build, test and pull request checklist.
## Start from an existing class
- Find the class with the nearest table shape and copy its approach:
`Layer2/Aerohive.pm` (fixed `layers`), `Layer2/Baystack.pm`
(standard first, vendor fallback), `Layer3/Foundry.pm` and
`Layer3/Aruba.pm` (vendor PoE and port tables), `Airespace.pm`
(merged controller tables). For a wireless device, copy `Airespace.pm`
or `Layer3/Aruba.pm`, which follow "Wireless devices" below.
- Inherit from the class the device resolved to before your change, so its
behavior stays. A Net-SNMP based device that used to land on
`Layer3::NetSNMP` keeps its `lo` handling and `hrSystemUptime` only if the
new class inherits from `Layer3::NetSNMP`.
- Merge the parents' hashes in reverse `@ISA` order (`AGENTS.md`, "How
the library works"). Copying a class that orders them otherwise copies
the mismatch.
- Changing a shared role or a parent class (`Bridge.pm`, `LLDP.pm`,
`Entity.pm`, `Airespace.pm`) affects every subclass. Check which classes
inherit or override what you change, and test one of each.
## Detection
- Add the class to `device_type()` in `SNMP/Info.pm`: an enterprise map
entry, a `sysDescr` pattern, or both.
- Register the narrowest `sysObjectID` branch that holds the product.
A whole enterprise also captures the vendor's other product lines;
the vendor's products MIB, or its MIB's top-level branches, show them.
- Every GET added to `device_type()` runs for every device that reaches
it. Place a probe inside the narrowest branch (one enterprise, one
`sysObjectID` prefix), never across a shared enterprise such as Net-SNMP
`8072` unless the product reports only that `sysObjectID`.
- Read a probe with `_global()`, as the Synology probe does: it caches the
answer for the object and sends nothing in Offline mode. A raw
`$session->get` is sent again on every `device_type()` call.
- Anchor `sysDescr` patterns, and test that neighboring products still
reach their own classes.
- When the device returns no `sysServices`, `layers` is undef and the
calling application skips its forwarding table (layer 2) and ARP cache
(layer 3). Fall back rather than replace:
`$self->SUPER::layers() || '<bits>'` with the value a walk of the
product serves, or add layer 2 when `fw_mac` has rows
(`Layer3/Juniper.pm`).
## Choosing a data source
| Evidence in any walk of the vendor | Do |
|---|---|
| Standard (inherited method, IETF/IEEE MIB) present | Call the standard first, fall back to the vendor source. Comment why both exist. |
| Standard absent from every walk | Vendor source first. A standard fallback may follow it, since it is polled only when the vendor source returns nothing. Never poll an absent standard first. |
| No walk shows a source | Do not add it. Ask for a walk. |
- "Any walk" means every capture of the vendor available, not only one.
Check what the class already inherits before concluding a standard is
absent.
- Prefer IF-MIB values the device returns. Overriding `i_up`, `i_type` or
similar from a vendor table needs a comment saying what IF-MIB gets wrong.
- Do not build `ip_index`/`ip_table` from the address SNMP::Info was asked
to contact. It may be a NAT or proxy address, and the caller already
knows it.
- **Read the index, not only the values.** An object may be
`not-accessible`, so it exists only as part of each row's index: the
address in `lldpRemManAddrTable` (`LLDP.pm`), the client MAC in Aruba's
user table (`Layer3/Aruba.pm`), and on Arista the VNIs of each VXLAN
tunnel, served only as the index of the counters table
`aristaVxlanVtiVniCountersTable`, which also identifies the tunnel's
ifIndex where `ifType` cannot. Before concluding a device does not
report something, read the `INDEX` clause of every table that could
hold it, statistics and counters tables included, and decode the index
as a walk shows it.
- **Take index values from the index, even when the index column is
readable.** Each column is a separate walk, so also reading an index
column repeats the table's round trips for data every row's key already
carries. Walk one column you need anyway, chosen so the agent fills it
on every row, and decode the rest from its index as the MIB types it: a
`MacAddress` is six sub-identifiers, and a variable-length string is
prefixed by its length unless the `INDEX` is `IMPLIED`.
- **Derive from the address plan only as a last resort.** Vendors
allocate MAC addresses from a base address in fixed patterns: a virtual
management MAC at an offset from the chassis base (`Layer3/Passport.pm`),
BSSIDs counted up from a radio's MAC (`Airespace.pm` `i_ssidmac`). Use
such a pattern only when no object or index gives the relationship, the
vendor documents it (cite the document in a comment), every walk
available follows it, the test data keeps the relationship, and anything
the pattern cannot place is left out rather than guessed.
## Identity methods
- `vendor` and `os` return lowercase strings (`cisco meraki`, `rutos`).
- `model`: map known `sysObjectID`s; for unknown ones return
`$self->SUPER::model()`, never undef.
- Search the vendor's MIBs in netdisco-mibs for model, serial and
firmware version objects. Fill each one the device provides, and fall
back to `SUPER::` when it returns nothing.
- Parse `sysDescr` with anchored patterns, and test the edge forms: a
leading or trailing word, a missing field, a version with or without a
`V` prefix.
- Tables with an active and an inactive row (firmware images) are read by
their status column. Fall back to the first row only when no row has a
status.
## Table methods
- Name MIB objects when netdisco-mibs defines them, and list the MIB in
`%MIBS`. Use a numeric OID only when netdisco-mibs lacks or misnames the
object, with a comment naming the netdisco-mibs issue or PR and the MIB
object beside each OID.
- Since 3.981002, numeric OIDs in `%FUNCS` walk as table columns, with
bulkwalk and caching. Do not write custom walkers or generate methods in
a `no strict 'refs'` loop.
- A vendor MIB in netdisco-mibs is expected before a new class merges. Open
the netdisco-mibs PR alongside the class; `AGENTS.md` explains how CI
treats a MIB that is not yet released.
- With `UseEnums`, net-snmp applies a loaded MIB's enum even to a numeric
read, for the values the enum defines. Munge labels back when needed.
- Fallbacks by kind: raw `%FUNCS` columns return undef when empty, so
`$self->SUPER::x() || $self->vendor_x()` works. Derived methods return
`{}`, which is true: test `keys %{...}`. Know what the inherited method
already tries before adding one.
- One source per protocol per device. Decide it once and memoize it under
an `_` key that `clear_cache` resets; empty walks are never cached. Mixed
key formats between methods double neighbors.
- A table indexed by a TimeFilter (`dot1qVlanCurrentTable`,
`dot1qFdbTable`, `lldpRemTable`) can walk forever on some agents: the
index keeps increasing, so loop detection never fires. If a device's
walk of one does not end, query with a partial of `0` (no time filter)
and keep a fallback, as `Layer3/Enterasys.pm` does. Do it in the device
class, not the shared role: agents that report rows only under their
last-change timemark (Junos, Comware) may return nothing under `0`.
- Map vendor states into the standard's value set (PoE status is a
`pethPsePortDetectionStatus` value, never `unknown`).
- Bridge port 0 means "not learned": drop it. Match a port number against
the port field of `unit/slot/port`, never loosely.
- `topo()` merges every protocol; the caller deduplicates. A vendor
neighbor table is another protocol beside LLDP and CDP, not a
replacement. `has_topo` lists a protocol only when `can("hasX")` and
`hasX()` are true: define `hasX` only for protocols the class supports,
and make a vendor-backed `hasX` probe for data rather than return a
constant.
## What the calling application reads
Netdisco, the main consumer, reads only these, so data elsewhere is not
stored. Its discover, macsuck and arpnip workers
(`App::Netdisco::Worker::Plugin::*`) are the reference.
- **Device**: `vendor`, `os`, `os_ver`, `model`, `serial`, `chassis_id`,
`mac`, `layers`, `uptime`, `name`, `description`, `contact`,
`location`, `ports`, `slots`, `fan`, `ps1_type`/`ps1_status`,
`ps2_type`/`ps2_status`, `snmpEngineID`. Vendor environment tables
belong in `fan` and `ps*_status`.
- **Addresses**: `ip_index`, `ip_table`, `ip_netmask`, `ipv6_addr`,
`ipv6_index`, `ipv6_addr_prefixlength`, `ipv6_type`, `vrf_name`.
Arpnip also takes subnets from `ip_netmask`.
- **Ports** keyed by `interfaces()`, minus those in `i_ignore`:
`i_description`, `i_name` (ifAlias), `i_type`, `i_mtu`, `i_mac`,
`i_up`/`i_up_admin`, `i_speed`/`i_speed_admin`/`i_speed_raw`,
`i_duplex`/`i_duplex_admin`, `i_lastchange`, `i_vlan`, `i_stp_state`,
`i_subinterfaces`, `agg_ports`, `i_err_disable_cause`,
`i_faststart_enabled`. Ignore interfaces that are not ports (VLAN,
loopback, management router interfaces) when the base class does not.
- **VLANs**: `v_name`, `v_index`, `i_vlan`, `i_vlan_type`,
`i_vlan_membership`(`_untagged`). Compare a vendor VLAN table with
Q-BRIDGE: one may list VLANs the other omits.
- **Forwarding** (devices with layer 2 in `layers`): `fw_mac` and
`fw_port` through `bp_index` to `interfaces`, each entry's VLAN from
`qb_fw_vlan`. `bp_index` must map every port `fw_port` returns; an
unmapped port's entries are dropped. Where `fw_port` returns ifIndexes,
map them to themselves from `interfaces()` or `fw_port()`, not from a
column a walk may lack. Overriding `bp_index` replaces BRIDGE-MIB's map
for every port, so keep the inherited one where the device serves it.
Per-VLAN walks use `v_name`, `v_state`, `i_vlan_membership`,
`i_auth_vlan_membership` and `cisco_comm_indexing`. `fw_status` is not
read.
- **ARP and neighbor cache** (layer 3): `at_paddr`/`at_netaddr`,
`ipv6_n2p_mac`/`ipv6_n2p_addr`.
- **Neighbors**: `has_topo`, then `c_ip` (and `lldp_ipv6`), `c_if`,
`c_port`, `c_id`, `c_platform`, `c_cap`. Only entries with a `c_ip` are
read. A `c_ip` that is unusable (`0.0.0.0`, loopback) is looked up by
`c_id` as a name or MAC; an entry with no `c_ip` is dropped.
`lldp_rem_vendor`, `lldp_rem_model`, `lldp_rem_serial`,
`lldp_rem_sw_rev` and `lldp_media_cap` describe the far end.
- **Routing peers** queued for discovery: `bgp_peer_addr`, `ospf_peers`,
`ospf_peer_id`, `isis_peers`, `eigrp_peers`.
- **Inventory**: `e_index`, `e_class`, `e_descr`, `e_name`, `e_type`,
`e_model`, `e_serial`, `e_hwver`, `e_fwver`, `e_swver`, `e_fru`,
`e_parent`, `e_pos`.
- **PoE**: `peth_power_watts`, `peth_power_status`, and `peth_port_*`
(`admin`, `status`, `class`, `power`) keyed `module.port`, mapped to
ports by `peth_port_ifindex`.
- **Wireless**: `i_ssidlist`, `i_ssidmac`, `i_ssidbcast`, `i_80211channel`,
`dot11_cur_tx_pwr_mw`, keyed by `interfaces()` key. A vendor table keyed
otherwise, even where its MIB says `INDEX { ifIndex }`, is mapped to
ifIndex first; an unmatched key is dropped. Clients from `cd11_*`. See
"Wireless devices" below for how the ports and keys fit together.
- **802.1X**: `dot1x`, `pae_*`.
Vendor-named methods (`vendor_wifi_essid`) are not read. Emulate the
standard method from the vendor table instead.
## Wireless devices
These rules apply to classes for access points and wireless controllers.
A radio is to an access point what a physical port is to a switch: the
medium, and properties such as channel and transmit power belong to it,
as speed and duplex belong to a port. SSIDs are carried on the radio the
way VLANs are carried on a trunk: one radio serves several, and the
calling application stores them per port, one row per BSSID. A client is
a node on the radio's port and carries two separate tags, its VLAN
(`qb_fw_vlan`) and its SSID (`cd11_ssid`); one SSID can place clients in
different VLANs, and several SSIDs can share one.
The calling application's wireless reports join these on the port: a
client counts only on a port that has a channel.
- **One port per radio.** Return one `interfaces()` entry per AP radio,
and key `i_80211channel` and `dot11_cur_tx_pwr_mw` by it.
- **SSIDs on the radio's port.** Key `i_ssidlist`, `i_ssidmac` and
`i_ssidbcast` as `<radio key>.<n>`. The caller removes the last
component to find the port, and stores one row per BSSID, so a radio
carries every SSID it serves.
- **Do not make a WLAN or SSID a port, or a subinterface of its radio.**
Clients on a WLAN port never meet the radio's channel, because Netdisco
moves nodes to a parent port only for link aggregation. Copying the
channel onto every WLAN port instead counts each radio once per SSID.
- **Map each WLAN to its radio from the device's data.** Prefer a column
or an index that names the radio, as `Airespace.pm` and
`Layer3/Aruba.pm` have. Where none exists, derive it from the address
plan, under the conditions in "Choosing a data source".
- **Check what a controller parent reads.** A vendor's standalone access
points may serve none of its controller's tables, so a subclass can
inherit `i_ssidlist`, `cd11_*` and `fw_*` methods that return nothing.
Override them from the tables the device serves.
### Wireless clients
These rules apply to the clients associated with an access point or
controller.
- **Place each client on its radio's port.** `fw_mac` and `fw_port` give
the client and its BSSID or radio, and `bp_index` maps that value to the
radio's `interfaces()` key. Keep the inherited bridge map for wired
ports.
- **Tag each client with its SSID through `cd11_ssid`.** The calling
application stores the SSID per client, as it stores a VLAN per node.
Emulate the `cd11_*` methods from the vendor's client table: `cd11_mac`,
`cd11_ssid`, `cd11_txrate` (a reference to an array of rates in Mbps,
of which the last is used), and the signal and traffic counters the
table has.
- **Key every `cd11_*` method like `cd11_txrate`.** The caller pairs them
by key, and reads no client data when `cd11_txrate` is empty.
## Check it against a walk
Before opening the pull request, serve a walk of the device (an SNMP
simulator on a private port, or the mocked session) and run the class
against it. Reading the code is not evidence.
- Check that the keys join, for every method the calling application
reads: `i_*` and wireless keys appear in `interfaces()`, and every
`fw_port` value appears in `bp_index`.
- Check `layers`: it includes layer 2 when `fw_mac` should be collected.
- For a wireless device, check that every client's port also has an
`i_80211channel` row, and that each radio's channel is on one port only.
- Count the GETs `device_type()` sends to a neighboring device of the same
enterprise, before and after the change.
- With no walk of the product, ask for one (`AGENTS.md`, "Device data")
rather than build the class from the MIB alone.
## Tests
- One test class per device class, one test method per public sub.
- Test data is synthetic and derived from real walks at full fidelity: same
OIDs, index shapes, value forms, edge cases. Never commit the walk.
- Copy each index as the walk shows it, including where the agent departs
from its MIB's `INDEX`. Data shaped from the MIB passes where the device
fails. Prime `_layers`, `_description` and `_id` with the walk's values;
`_layers` is the raw `sysServices` integer.
- Keep the relationships between values when you anonymize them, not only
their consistency: a BSSID counted up from its radio's MAC, addresses
that share a prefix, an index that embeds another table's value.
Sequential placeholders that drop such a relationship cannot test the
code that depends on it.
- Every fallback path is exercised by a test, using data shaped like the
walk that needs it.
- Add a `device_type` test method to the class's test class
(`Layer3/Siemens.pm`): a case for each detection rule, and one showing
a neighboring product still goes to its own class.
## POD
A new class uses this outline (`Layer2/Aerohive.pm`, `Layer2/Baystack.pm`):
```
=head1 NAME / AUTHOR (SNMP::Info Developers) / SYNOPSIS
=head1 DESCRIPTION
=head2 Inherited Classes one =item per parent
=head2 Required MIBs one F<MIB> item per %MIBS entry the class adds
=head2 Inherited MIBs L<Parent/"Required MIBs"> per parent
=head1 GLOBALS
=head2 Overrides
=head2 Global Methods imported from <Parent> one per parent
=head1 TABLE METHODS
=head2 Overrides
=head2 <Vendor> <Table Name> (C<vendorTable>) one per vendor table
=head2 <Standard Feature> Table emulation, with an intro:
"These methods emulate the F<STD-MIB> C<stdTable> methods
using the F<VENDOR-MIB> C<vendorTable>."
=head2 Table Methods imported from <Parent> one per parent
=head1 Data Munging Callback Subroutines
```
- Each item is `=item $obj->method()`, a blank line, text saying what it
returns and from where, and the leaf in `(C<leaf>)`. Table items start
"Returns reference to hash."
- Document every `%GLOBALS`/`%FUNCS` entry and public sub the class
defines, once. Inherited methods appear only in the "imported from"
sections. Private `_` subs are not documented.
- A new class names `SNMP::Info Developers` under AUTHOR and as the
copyright holder, not a person; git history records who wrote what. Keep the existing AUTHOR and
copyright lines of a class you change.
- Check rendering with `pod2text`: each `=item` must render as its own
heading.
## Files and Changes
- Add new files to `MANIFEST`. Do not edit `$VERSION`, `README` or
`META.*`; maintainers set versions at release.
- `Changes`: under the top, undated `Version` heading, in the existing
`[ENHANCEMENTS]` or `[BUG FIXES]` section, as
`* #PR Vendor: what changed (#issue)`.
- PR text says what changed, why and how it was verified, and links
related issues.