feat(12-01): add RF monitor collector, WIRELESS_REGISTRATIONS stream, wire into poll cycle

- RFMonitorStats struct for per-interface RF data (noise floor, channel width, TX power)
- CollectRFMonitor with v6/v7 RouterOS version routing
- WIRELESS_REGISTRATIONS NATS stream with 30-day retention (separate from DEVICE_EVENTS)
- WirelessRegistrationEvent type and PublishWirelessRegistrations method
- Poll cycle collects per-client registrations and RF stats, publishes combined event

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 05:38:14 -05:00
parent 390c4c1297
commit caa33ca8d7
4 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
package device
import (
"log/slog"
"strconv"
routeros "github.com/go-routeros/routeros/v3"
)
// RFMonitorStats holds per-interface RF monitoring data collected from
// the RouterOS monitor command. These stats describe the RF environment
// rather than individual clients.
type RFMonitorStats struct {
Interface string `json:"interface"`
NoiseFloor int `json:"noise_floor"` // dBm, e.g. -105
ChannelWidth string `json:"channel_width"` // e.g. "20MHz", "40MHz"
TxPower int `json:"tx_power"` // dBm, e.g. 24
RegisteredClients int `json:"registered_clients"` // count from monitor
}
// CollectRFMonitor queries the RouterOS device for per-interface RF statistics
// using the monitor command.
//
// Version routing:
// - majorVersion >= 7: runs /interface/wifi/print to list interfaces, then
// /interface/wifi/monitor for each interface.
// - majorVersion < 7: runs /interface/wireless/print to list interfaces,
// then /interface/wireless/monitor for each interface.
//
// Returns nil, nil when the device has no wireless interfaces.
func CollectRFMonitor(client *routeros.Client, majorVersion int) ([]RFMonitorStats, error) {
var printCmd, monitorCmd string
if majorVersion >= 7 {
printCmd = "/interface/wifi/print"
monitorCmd = "/interface/wifi/monitor"
} else {
printCmd = "/interface/wireless/print"
monitorCmd = "/interface/wireless/monitor"
}
// List wireless interface names.
listReply, err := client.Run(printCmd, "=.proplist=name")
if err != nil {
slog.Debug("device has no wireless interfaces for RF monitor", "error", err)
return nil, nil
}
if len(listReply.Re) == 0 {
return nil, nil
}
stats := make([]RFMonitorStats, 0, len(listReply.Re))
for _, s := range listReply.Re {
ifaceName := s.Map["name"]
if ifaceName == "" {
continue
}
// Run monitor command for this interface.
monReply, monErr := client.Run(monitorCmd, "=numbers="+ifaceName, "=once=")
if monErr != nil {
slog.Debug("RF monitor command failed for interface",
"interface", ifaceName, "command", monitorCmd, "error", monErr)
continue
}
if len(monReply.Re) == 0 {
continue
}
m := monReply.Re[0].Map
entry := RFMonitorStats{
Interface: ifaceName,
}
// Noise floor.
if nf, parseErr := strconv.Atoi(m["noise-floor"]); parseErr == nil {
entry.NoiseFloor = nf
}
// Channel width: v6 uses "channel-width", v7 may use "channel" with width embedded.
if majorVersion >= 7 {
entry.ChannelWidth = m["channel"]
} else {
entry.ChannelWidth = m["channel-width"]
}
// TX power.
if txp, parseErr := strconv.Atoi(m["tx-power"]); parseErr == nil {
entry.TxPower = txp
}
// Registered clients count.
if rc, parseErr := strconv.Atoi(m["registered-clients"]); parseErr == nil {
entry.RegisteredClients = rc
}
stats = append(stats, entry)
}
if len(stats) == 0 {
return nil, nil
}
return stats, nil
}

View File

@@ -0,0 +1,32 @@
package device
import (
"testing"
)
func TestRFMonitorStatsFields(t *testing.T) {
// Compilation test: ensure RFMonitorStats has all required fields
// with correct types.
stats := RFMonitorStats{
Interface: "wlan1",
NoiseFloor: -105,
ChannelWidth: "20MHz",
TxPower: 24,
RegisteredClients: 15,
}
if stats.Interface != "wlan1" {
t.Error("Interface field not set correctly")
}
if stats.NoiseFloor != -105 {
t.Error("NoiseFloor field not set correctly")
}
if stats.ChannelWidth != "20MHz" {
t.Error("ChannelWidth field not set correctly")
}
if stats.TxPower != 24 {
t.Error("TxPower field not set correctly")
}
if stats.RegisteredClients != 15 {
t.Error("RegisteredClients field not set correctly")
}
}