package econetmetrics import ( "context" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "gitea.libretechconsulting.com/rmcguire/econet-exporter/pkg/econet/econetclient" ) // registerLive wires the per-device live-state gauges to a single callback // that snapshots client.Devices() once per scrape (cheap, in-memory). func (c *Collector) registerLive() error { b := &gaugeBuilder{m: c.meter} g := &liveGauges{ setpoint: b.i64("econet_setpoint_fahrenheit", "Target water temperature (°F)"), setpointMin: b.i64("econet_setpoint_min_fahrenheit", "Minimum allowed setpoint (°F)"), setpointMax: b.i64("econet_setpoint_max_fahrenheit", "Maximum allowed setpoint (°F)"), connected: b.i64("econet_connected", "1 if the device is connected"), running: b.i64("econet_running", "1 if the device is actively heating (running_state label names the stage, e.g. compressor-running)"), mode: b.i64("econet_mode", "Operating mode enum: 1 for the current mode, 0 otherwise (see the mode label)"), enabled: b.i64("econet_enabled", "1 if the device is enabled"), away: b.i64("econet_away", "1 if away mode is on"), hotWater: b.i64("econet_hot_water_availability_percent", "Hot water availability (%)"), alerts: b.i64("econet_alert_count", "Number of active alerts"), wifi: b.i64("econet_wifi_signal_db", "WiFi signal strength (dB)"), lastUpdated: b.i64("econet_last_updated_seconds", "Unix time of last state update"), info: b.i64("econet_device_info", "Device metadata (value is always 1)"), } if b.err != nil { return b.err } _, err := c.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error { for _, d := range c.client.Devices() { g.observe(o, d) } return nil }, b.obs...) return err } // registerUsage wires the energy/water gauges, reading the usage totals the // poller folds into each device snapshot. func (c *Collector) registerUsage() error { b := &gaugeBuilder{m: c.meter} g := &usageGauges{ energy: b.f64("econet_energy_usage_kwh", "Energy used today (kWh)"), cost: b.f64("econet_energy_cost_dollars", "Energy cost today in US dollars (kWh * costPerKWH)"), water: b.f64("econet_water_usage_gallons", "Water used today (gallons)"), } if b.err != nil { return b.err } _, err := c.meter.RegisterCallback(func(_ context.Context, o metric.Observer) error { for _, d := range c.client.Devices() { g.observe(o, d, c.client.CostPerKWH()) } return nil }, b.obs...) return err } type liveGauges struct { setpoint, setpointMin, setpointMax metric.Int64ObservableGauge connected, running, enabled, away metric.Int64ObservableGauge hotWater, alerts, wifi, lastUpdated metric.Int64ObservableGauge mode metric.Int64ObservableGauge info metric.Int64ObservableGauge } func (g *liveGauges) observe(o metric.Observer, d *econetclient.Device) { set := metric.WithAttributes(deviceAttrs(d)...) o.ObserveInt64(g.setpoint, int64(d.Setpoint), set) o.ObserveInt64(g.setpointMin, int64(d.SetpointMin), set) o.ObserveInt64(g.setpointMax, int64(d.SetpointMax), set) o.ObserveInt64(g.connected, b2i(d.Connected), set) runSet := metric.WithAttributes(append(deviceAttrs(d), attribute.String("econet.running_state", d.RunningState))...) o.ObserveInt64(g.running, b2i(d.Running), runSet) o.ObserveInt64(g.enabled, b2i(d.Enabled), set) o.ObserveInt64(g.away, b2i(d.Away), set) o.ObserveInt64(g.hotWater, int64(d.HotWaterAvailability), set) o.ObserveInt64(g.alerts, int64(d.AlertCount), set) o.ObserveInt64(g.wifi, int64(d.WiFiSignal), set) if !d.LastUpdated.IsZero() { o.ObserveInt64(g.lastUpdated, d.LastUpdated.Unix(), set) } for _, m := range econetclient.KnownModes { modeSet := metric.WithAttributes(append(deviceAttrs(d), attribute.String("econet.mode", m))...) o.ObserveInt64(g.mode, b2i(m == d.Mode), modeSet) } o.ObserveInt64(g.info, 1, metric.WithAttributes(infoAttrs(d)...)) } type usageGauges struct { energy, cost, water metric.Float64ObservableGauge } func (g *usageGauges) observe(o metric.Observer, d *econetclient.Device, costPerKWH float64) { serial := attribute.String("econet.serial", d.SerialNumber) o.ObserveFloat64(g.energy, d.EnergyKWH, metric.WithAttributes( serial, attribute.String("econet.energy_type", d.EnergyType), )) o.ObserveFloat64(g.water, d.WaterGallons, metric.WithAttributes(serial)) if d.EnergyType == "KWH" { o.ObserveFloat64(g.cost, d.EnergyKWH*costPerKWH, metric.WithAttributes(serial)) } } // deviceAttrs are low-cardinality identity attributes safe for every series. // They follow OTEL semantic-convention style (namespaced, dotted keys) and // deliberately exclude anything that changes over time (e.g. timestamps). func deviceAttrs(d *econetclient.Device) []attribute.KeyValue { return []attribute.KeyValue{ attribute.String("econet.serial", d.SerialNumber), attribute.String("econet.device_id", d.DeviceID), attribute.String("econet.friendly_name", d.FriendlyName), } } func infoAttrs(d *econetclient.Device) []attribute.KeyValue { return append(deviceAttrs(d), attribute.String("econet.type", d.Type), attribute.String("econet.generic_type", d.GenericType), attribute.String("econet.mode", d.Mode), ) } func b2i(b bool) int64 { if b { return 1 } return 0 }