From 23d6b38a4dc5b8825e62d80781c89ed4580d76c3 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Thu, 19 Mar 2026 05:36:08 -0500 Subject: [PATCH] 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) --- poller/internal/device/registration.go | 151 ++++++++++++++++++++ poller/internal/device/registration_test.go | 64 +++++++++ 2 files changed, 215 insertions(+) create mode 100644 poller/internal/device/registration.go create mode 100644 poller/internal/device/registration_test.go diff --git a/poller/internal/device/registration.go b/poller/internal/device/registration.go new file mode 100644 index 0000000..ccd17fa --- /dev/null +++ b/poller/internal/device/registration.go @@ -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 +} diff --git a/poller/internal/device/registration_test.go b/poller/internal/device/registration_test.go new file mode 100644 index 0000000..2f54c48 --- /dev/null +++ b/poller/internal/device/registration_test.go @@ -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") + } +}