feat(12-01): add per-client wireless registration collector and signal parser
- RegistrationEntry struct for per-client wireless data (MAC, signal, CCQ, rates, distance) - ParseSignalStrength handles all RouterOS format variations (-67, -67@5GHz, -67@HT40) - CollectRegistrations with v6/v7 RouterOS version routing - Unit tests for ParseSignalStrength covering 10 cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
151
poller/internal/device/registration.go
Normal file
151
poller/internal/device/registration.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
routeros "github.com/go-routeros/routeros/v3"
|
||||
)
|
||||
|
||||
// RegistrationEntry holds per-client wireless registration data from a single
|
||||
// row of the RouterOS registration-table. Each connected wireless client
|
||||
// produces one entry.
|
||||
type RegistrationEntry struct {
|
||||
Interface string `json:"interface"`
|
||||
MacAddress string `json:"mac_address"`
|
||||
SignalStrength int `json:"signal_strength"` // dBm, parsed from signal-strength or signal
|
||||
TxCCQ int `json:"tx_ccq"` // 0-100, 0 if unavailable (v7)
|
||||
TxRate string `json:"tx_rate"` // e.g. "130Mbps"
|
||||
RxRate string `json:"rx_rate"` // e.g. "130Mbps"
|
||||
Uptime string `json:"uptime"` // RouterOS duration format e.g. "3d12h5m"
|
||||
Distance int `json:"distance"` // meters, 0 if unavailable
|
||||
LastIP string `json:"last_ip"` // client IP if available
|
||||
TxSignalStrength int `json:"tx_signal_strength"` // dBm, 0 if unavailable
|
||||
Bytes string `json:"bytes"` // "tx,rx" format string from RouterOS
|
||||
}
|
||||
|
||||
// ParseSignalStrength parses a RouterOS signal strength string into an integer dBm value.
|
||||
//
|
||||
// RouterOS returns signal strength in several formats:
|
||||
// - "-67" (plain integer)
|
||||
// - "-67@5GHz" (with frequency suffix)
|
||||
// - "-67@HT40" (with HT width suffix)
|
||||
// - "-80@5GHz-Ce/a/ac/an" (with complex suffix)
|
||||
//
|
||||
// The function strips everything from the first '@' character onward and
|
||||
// parses the remaining string as an integer. An empty string returns 0, nil
|
||||
// (zero value for missing data).
|
||||
func ParseSignalStrength(s string) (int, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Strip everything from @ onward.
|
||||
if idx := strings.IndexByte(s, '@'); idx >= 0 {
|
||||
s = s[:idx]
|
||||
}
|
||||
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parsing signal strength %q: %w", s, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// CollectRegistrations queries the RouterOS device for the wireless
|
||||
// registration-table and returns one RegistrationEntry per connected client.
|
||||
//
|
||||
// Version routing:
|
||||
// - majorVersion >= 7: tries /interface/wifi/registration-table/print first;
|
||||
// falls back to /interface/wireless/registration-table/print if that fails.
|
||||
// - majorVersion < 7 (including 0 for unknown): uses the classic wireless path.
|
||||
//
|
||||
// Returns nil, nil when the device has no wireless interfaces (same pattern
|
||||
// as CollectWireless).
|
||||
func CollectRegistrations(client *routeros.Client, majorVersion int) ([]RegistrationEntry, error) {
|
||||
var rows []map[string]string
|
||||
var useV7WiFi bool
|
||||
|
||||
if majorVersion >= 7 {
|
||||
// Try the v7 WiFi API first.
|
||||
reply, err := client.Run("/interface/wifi/registration-table/print")
|
||||
if err == nil {
|
||||
useV7WiFi = true
|
||||
for _, s := range reply.Re {
|
||||
rows = append(rows, s.Map)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("v7 wifi registration-table not available, falling back to wireless", "error", err)
|
||||
reply, err = client.Run("/interface/wireless/registration-table/print")
|
||||
if err != nil {
|
||||
slog.Debug("device has no wireless interfaces", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
for _, s := range reply.Re {
|
||||
rows = append(rows, s.Map)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
reply, err := client.Run("/interface/wireless/registration-table/print")
|
||||
if err != nil {
|
||||
slog.Debug("device has no wireless interfaces", "error", err)
|
||||
return nil, nil
|
||||
}
|
||||
for _, s := range reply.Re {
|
||||
rows = append(rows, s.Map)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries := make([]RegistrationEntry, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
entry := RegistrationEntry{
|
||||
Interface: r["interface"],
|
||||
MacAddress: r["mac-address"],
|
||||
TxRate: r["tx-rate"],
|
||||
RxRate: r["rx-rate"],
|
||||
Uptime: r["uptime"],
|
||||
LastIP: r["last-ip"],
|
||||
Bytes: r["bytes"],
|
||||
}
|
||||
|
||||
// Signal strength: v7 wifi uses "signal", v6 wireless uses "signal-strength".
|
||||
sigField := "signal-strength"
|
||||
if useV7WiFi {
|
||||
sigField = "signal"
|
||||
}
|
||||
if sig, err := ParseSignalStrength(r[sigField]); err != nil {
|
||||
slog.Debug("could not parse signal strength", "value", r[sigField], "error", err)
|
||||
} else {
|
||||
entry.SignalStrength = sig
|
||||
}
|
||||
|
||||
// TX signal strength (may not be present).
|
||||
if txSig, err := ParseSignalStrength(r["tx-signal-strength"]); err != nil {
|
||||
slog.Debug("could not parse tx-signal-strength", "value", r["tx-signal-strength"], "error", err)
|
||||
} else {
|
||||
entry.TxSignalStrength = txSig
|
||||
}
|
||||
|
||||
// TX CCQ: available in v6 wireless, not in v7 wifi package.
|
||||
if !useV7WiFi {
|
||||
if ccq, err := strconv.Atoi(r["tx-ccq"]); err == nil {
|
||||
entry.TxCCQ = ccq
|
||||
}
|
||||
}
|
||||
|
||||
// Distance.
|
||||
if dist, err := strconv.Atoi(r["distance"]); err == nil {
|
||||
entry.Distance = dist
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
64
poller/internal/device/registration_test.go
Normal file
64
poller/internal/device/registration_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package device
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSignalStrength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "plain negative", input: "-67", want: -67},
|
||||
{name: "with 5GHz suffix", input: "-67@5GHz", want: -67},
|
||||
{name: "with 2.4GHz suffix", input: "-72@2.4GHz", want: -72},
|
||||
{name: "empty string", input: "", want: 0},
|
||||
{name: "invalid string", input: "abc", want: 0, wantErr: true},
|
||||
{name: "with HT40 suffix", input: "-67@HT40", want: -67},
|
||||
{name: "with HT20 suffix", input: "-55@HT20", want: -55},
|
||||
{name: "positive value", input: "10", want: 10},
|
||||
{name: "zero", input: "0", want: 0},
|
||||
{name: "with complex suffix", input: "-80@5GHz-Ce/a/ac/an", want: -80},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ParseSignalStrength(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseSignalStrength(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseSignalStrength(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationEntryFields(t *testing.T) {
|
||||
// Compilation test: ensure RegistrationEntry has all required fields.
|
||||
entry := RegistrationEntry{
|
||||
Interface: "wlan1",
|
||||
MacAddress: "AA:BB:CC:DD:EE:FF",
|
||||
SignalStrength: -67,
|
||||
TxCCQ: 95,
|
||||
TxRate: "130Mbps",
|
||||
RxRate: "130Mbps",
|
||||
Uptime: "3d12h5m",
|
||||
Distance: 150,
|
||||
LastIP: "192.168.1.100",
|
||||
TxSignalStrength: -65,
|
||||
Bytes: "123456,789012",
|
||||
}
|
||||
if entry.Interface != "wlan1" {
|
||||
t.Error("Interface field not set correctly")
|
||||
}
|
||||
if entry.MacAddress != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Error("MacAddress field not set correctly")
|
||||
}
|
||||
if entry.SignalStrength != -67 {
|
||||
t.Error("SignalStrength field not set correctly")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user