Files
the-other-dude/poller/internal/device/registration.go
Jason Staack 23d6b38a4d 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>
2026-03-19 05:36:08 -05:00

152 lines
4.7 KiB
Go

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
}