From 1818b9ba7bb501b13903dea7637d30fae3f0d485 Mon Sep 17 00:00:00 2001 From: gnacho Date: Wed, 23 Sep 2026 10:17:00 +0200 Subject: [PATCH] feat(mqtt): expose client and traffic telemetry to Home Assistant Add aggregated client telemetry (total, Wi-Fi/wired split, weak-signal count and cumulative RX/TX bytes) to the MQTT state, with the matching discovery entities. Built from the existing client probe, cached briefly so the periodic publish does not add a second fork storm on top of the panel's requests. Per-client entities are intentionally out of scope: a network with 100+ clients would flood Home Assistant. --- internal/modules/mqtt_ha.go | 66 +++++++++++++++++++++++++++++- internal/modules/telemetry.go | 61 +++++++++++++++++++++++++++ internal/modules/telemetry_test.go | 37 +++++++++++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 internal/modules/telemetry.go create mode 100644 internal/modules/telemetry_test.go diff --git a/internal/modules/mqtt_ha.go b/internal/modules/mqtt_ha.go index f77b731..9710f0d 100644 --- a/internal/modules/mqtt_ha.go +++ b/internal/modules/mqtt_ha.go @@ -33,7 +33,16 @@ type mqttState struct { IPv6Enabled bool `json:"ipv6_enabled"` SQMEnabled bool `json:"sqm_enabled"` Mode string `json:"mode"` - Ts int64 `json:"ts"` + // Aggregated client telemetry (#401). + ClientsTotal int `json:"clients_total"` + ClientsWifi int `json:"clients_wifi"` + Clients24 int `json:"clients_24"` + Clients5 int `json:"clients_5"` + ClientsCable int `json:"clients_cable"` + ClientsWeak int `json:"clients_weak"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` + Ts int64 `json:"ts"` } type mqttBoard struct { @@ -83,6 +92,16 @@ func buildMQTTState(node, version string) mqttState { st.SQMEnabled = ProbeSQM().Active st.Mode = ProbeMode().Mode + tel := ProbeTelemetry() + st.ClientsTotal = tel.Total + st.ClientsWifi = tel.Wifi24 + tel.Wifi5 + st.Clients24 = tel.Wifi24 + st.Clients5 = tel.Wifi5 + st.ClientsCable = tel.Cable + st.ClientsWeak = tel.Weak + st.RxBytes = tel.RxBytes + st.TxBytes = tel.TxBytes + return st } @@ -259,5 +278,50 @@ func mqttDiscoveryEntities(node, version, model string) []mqttEntity { "icon": "mdi:information-outline", "entity_category": "diagnostic", }), + newEntity("sensor", "clients_total", map[string]any{ + "name": "Clients", + "value_template": "{{ value_json.clients_total }}", + "state_class": "measurement", + "icon": "mdi:account-network", + }), + newEntity("sensor", "clients_wifi", map[string]any{ + "name": "Wi-Fi clients", + "value_template": "{{ value_json.clients_wifi }}", + "state_class": "measurement", + "icon": "mdi:wifi", + "entity_category": "diagnostic", + }), + newEntity("sensor", "clients_cable", map[string]any{ + "name": "Wired clients", + "value_template": "{{ value_json.clients_cable }}", + "state_class": "measurement", + "icon": "mdi:ethernet", + "entity_category": "diagnostic", + }), + newEntity("sensor", "clients_weak", map[string]any{ + "name": "Weak-signal clients", + "value_template": "{{ value_json.clients_weak }}", + "state_class": "measurement", + "icon": "mdi:wifi-alert", + "entity_category": "diagnostic", + }), + newEntity("sensor", "rx_bytes", map[string]any{ + "name": "Clients received", + "value_template": "{{ value_json.rx_bytes }}", + "unit_of_measurement": "B", + "device_class": "data_size", + "state_class": "total_increasing", + "icon": "mdi:download-network", + "entity_category": "diagnostic", + }), + newEntity("sensor", "tx_bytes", map[string]any{ + "name": "Clients sent", + "value_template": "{{ value_json.tx_bytes }}", + "unit_of_measurement": "B", + "device_class": "data_size", + "state_class": "total_increasing", + "icon": "mdi:upload-network", + "entity_category": "diagnostic", + }), } } diff --git a/internal/modules/telemetry.go b/internal/modules/telemetry.go new file mode 100644 index 0000000..289b20c --- /dev/null +++ b/internal/modules/telemetry.go @@ -0,0 +1,61 @@ +// telemetry.go: aggregated client and traffic telemetry (#401) used by the +// MQTT integration to expose router activity to Home Assistant without a +// NetPulse server. +package modules + +import "time" + +// TelemetryProbe is the aggregated view of the connected clients. +type TelemetryProbe struct { + Total int `json:"total"` + Wifi24 int `json:"wifi24"` + Wifi5 int `json:"wifi5"` + Cable int `json:"cable"` + Weak int `json:"weak"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +// weakSignalDbm: clients at or below this signal count as weak. Chosen from +// the usual Wi-Fi thresholds; a value of 0 means "no measurement" and never +// counts. +const weakSignalDbm = -75 + +// telemetryTTL dedupes the probe between the panel and the MQTT publisher: +// ListClients forks several commands, so it is cached briefly. +const telemetryTTL = 15 * time.Second + +// ProbeTelemetry returns the aggregated client telemetry, cached for +// telemetryTTL. +func ProbeTelemetry() *TelemetryProbe { + p, _ := CachedRead("telemetry", telemetryTTL, func() (*TelemetryProbe, error) { + return aggregateClients(ListClients("")), nil + }) + if p == nil { + return &TelemetryProbe{} + } + return p +} + +// aggregateClients folds a client list into the telemetry counters. Pure so it +// can be tested without touching the router. +func aggregateClients(clients []Client) *TelemetryProbe { + t := &TelemetryProbe{} + for _, c := range clients { + t.Total++ + switch c.Type { + case "wifi5": + t.Wifi5++ + case "wifi24": + t.Wifi24++ + default: + t.Cable++ + } + if c.Type != "cable" && c.Signal != 0 && c.Signal < weakSignalDbm { + t.Weak++ + } + t.RxBytes += c.RxBytes + t.TxBytes += c.TxBytes + } + return t +} diff --git a/internal/modules/telemetry_test.go b/internal/modules/telemetry_test.go new file mode 100644 index 0000000..e833f0b --- /dev/null +++ b/internal/modules/telemetry_test.go @@ -0,0 +1,37 @@ +package modules + +import "testing" + +func TestAggregateClients(t *testing.T) { + tel := aggregateClients([]Client{ + {Type: "wifi24", Signal: -50, RxBytes: 100, TxBytes: 200}, + {Type: "wifi5", Signal: -80, RxBytes: 300, TxBytes: 400}, // weak + {Type: "wifi5", Signal: 0, RxBytes: 1, TxBytes: 2}, // no measurement + {Type: "cable", RxBytes: 5, TxBytes: 6}, + }) + if tel.Total != 4 { + t.Fatalf("total = %d, want 4", tel.Total) + } + if tel.Wifi24 != 1 || tel.Wifi5 != 2 || tel.Cable != 1 { + t.Fatalf("band split = %d/%d/%d, want 1/2/1", tel.Wifi24, tel.Wifi5, tel.Cable) + } + if tel.Weak != 1 { + t.Fatalf("weak = %d, want 1 (signal 0 must not count)", tel.Weak) + } + if tel.RxBytes != 406 || tel.TxBytes != 608 { + t.Fatalf("bytes = %d/%d, want 406/608", tel.RxBytes, tel.TxBytes) + } +} + +func TestAggregateClientsEmpty(t *testing.T) { + tel := aggregateClients(nil) + if tel == nil || tel.Total != 0 { + t.Fatalf("expected a zero telemetry, got %+v", tel) + } +} + +func TestProbeTelemetryNeverNil(t *testing.T) { + if tel := ProbeTelemetry(); tel == nil { + t.Fatal("ProbeTelemetry returned nil") + } +}