Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-units-charge-math.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

Harden SI units and charging identities: SoC doors fold NaN/overflow instead of leaking percent or Inf, HA discovery slugs illegal driver names with a collision tag while leaving already-legal mixed-case ids unchanged, and synthetic history stores 0–1 SoC.
2 changes: 1 addition & 1 deletion go/internal/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ func TestLoadResearchDumpExportsHouseLoadWithEVSplit(t *testing.T) {
PVW: -500,
BatW: 0,
LoadW: 1800, // legacy whole-site load: grid - bat - pv
BatSoC: 55,
BatSoC: 0.55,
JSON: js,
}); err != nil {
t.Fatalf("record history: %v", err)
Expand Down
4 changes: 2 additions & 2 deletions go/internal/appproto/ev.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,8 @@ func (h *Handler) loadpointBoost(cmd Cmd, uptimeMs int64) error {
lease := loadpoint.BatteryBoostLease{
StartedAt: now,
ExpiresAt: expires,
MinBatterySoC: units.FractionFromLegacyPercent(minSoC),
EVTargetSoC: units.FractionFromLegacyPercent(evTarget),
MinBatterySoC: units.ClampFraction(units.FractionFromLegacyPercent(minSoC)),
EVTargetSoC: units.ClampFraction(units.FractionFromLegacyPercent(evTarget)),
}
if departureAtMs, ok := argInt(cmd.Args, "departure_at_ms"); ok && departureAtMs > 0 {
lease.DepartureAt = time.UnixMilli(departureAtMs)
Expand Down
4 changes: 3 additions & 1 deletion go/internal/appproto/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package appproto
import (
"math"
"strconv"

"github.com/srcfl/ftw/go/internal/units"
)

// Source id prefixes are the box's own; only the shape matters to the app.
Expand Down Expand Up @@ -96,7 +98,7 @@ func fieldValues(snap Snapshot, modes []ModeInfo) map[string]int64 {
fidKey(FidLoadW): roundW(snap.LoadW),
}
if snap.BatterySoCKnown {
f[fidKey(FidBatterySoc)] = int64(math.Round(snap.BatterySoC * 1000))
f[fidKey(FidBatterySoc)] = units.PermilleFromFraction(snap.BatterySoC)
}
if snap.EVWKnown {
f[fidKey(FidEvW)] = roundW(snap.EVW)
Expand Down
15 changes: 15 additions & 0 deletions go/internal/appproto/telemetry_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package appproto

import (
"math"
"testing"

"github.com/srcfl/ftw/go/internal/control"
Expand Down Expand Up @@ -99,6 +100,20 @@ func TestStateOfChargeIsPermilleOnTheWire(t *testing.T) {
}
}

func TestStateOfChargeNonFiniteIsZeroPermille(t *testing.T) {
h, box, rec, _ := newRig(t)
box.snap.BatterySoC = math.NaN()
box.snap.BatterySoCKnown = true
deliver(t, h, MsgHello, nil, Hello{Proto: ProtoRange{Min: 0, Max: ProtoMax}})
rec.reset()
deliver(t, h, MsgSub, nil, Sub{Bucket: 512, Hz: 1})

snap := body[Snap](t, rec.only(t, MsgSnap))
if got := snap.Fields[fidKey(FidBatterySoc)]; got != 0 {
t.Fatalf("NaN battery_soc = %d, want 0 permille", got)
}
}

// Silence would itself be information: it would tell the relay operator that
// nothing happened in the house that second.
func TestNothingChangedStillSendsATick(t *testing.T) {
Expand Down
14 changes: 7 additions & 7 deletions go/internal/devtools/backfill.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func Backfill(s *state.Store, cfg BackfillConfig, log *slog.Logger) error {

const capWh = 15000.0
const batchSize = 5000
soc := 50.0
soc := 0.50
written := 0
batch := make([]state.HistoryPoint, 0, batchSize)
reportEvery := total / 10
Expand Down Expand Up @@ -161,20 +161,20 @@ func Backfill(s *state.Store, cfg BackfillConfig, log *slog.Logger) error {
// Battery cascade with small efficiency jitter.
surplus := pvGen - load
batW := 0.0
if surplus > 0 && soc < 95 {
if surplus > 0 && soc < 0.95 {
batW = math.Min(surplus*(0.75+0.1*rng.Float64()), 5000)
} else if surplus < 0 && soc > 15 {
} else if surplus < 0 && soc > 0.15 {
batW = math.Max(surplus*(0.65+0.1*rng.Float64()), -4000)
}

// Grid residual + measurement noise.
gridW := load - pvGen + batW + 30*rng.NormFloat64()

// SoC update — Wh = W * dtH.
// SoC is a 0–1 fraction in history, matching production writes.
dtH := step.Seconds() / 3600.0
soc += batW * dtH / capWh * 100
if soc > 100 {
soc = 100
soc += batW * dtH / capWh
if soc > 1 {
soc = 1
}
if soc < 0 {
soc = 0
Expand Down
48 changes: 48 additions & 0 deletions go/internal/devtools/backfill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package devtools

import (
"log/slog"
"os"
"path/filepath"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/units"
)

func TestBackfillBatSoCIsFraction(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
s, err := state.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })

log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
if err := Backfill(s, BackfillConfig{Days: 1, Step: 15 * time.Minute, Seed: 42}, log); err != nil {
t.Fatal(err)
}
rows, err := s.LoadHistory(0, time.Now().UnixMilli()+1, 0)
if err != nil {
t.Fatal(err)
}
if len(rows) < 50 {
t.Fatalf("backfill wrote %d rows, want dozens", len(rows))
}
sawMid := false
for _, p := range rows {
if !units.ValidFraction(p.BatSoC) {
t.Fatalf("BatSoC %v is not a 0–1 fraction (history used to store 0–100 percent)", p.BatSoC)
}
if p.BatSoC > 0.2 && p.BatSoC < 0.8 {
sawMid = true
}
if p.BatSoC > 2 {
t.Fatalf("BatSoC %v looks like percent, not fraction", p.BatSoC)
}
}
if !sawMid {
t.Fatal("expected some samples in (0.2, 0.8); pack stuck at empty/full suggests the integrator used percent")
}
}
97 changes: 78 additions & 19 deletions go/internal/ha/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"log/slog"
"net"
"net/url"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/mdnsresolve"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/internal/units"
)

// CommandCallbacks is how the bridge hands received commands back to the
Expand Down Expand Up @@ -365,7 +367,67 @@ func (b *Bridge) availTopic() string { return b.topicPrefix + "/statu
func (b *Bridge) stateTopic(name string) string { return b.topicPrefix + "/state/" + name }
func (b *Bridge) cmdTopic(name string) string { return b.topicPrefix + "/cmd/" + name }
func (b *Bridge) driverTopic(driver, field string) string {
return fmt.Sprintf("%s/driver/%s/%s", b.topicPrefix, driver, field)
return fmt.Sprintf("%s/driver/%s/%s", b.topicPrefix, mqttObjectID(driver), mqttObjectID(field))
}

// mqttObjectID maps a free-form YAML driver name onto Home Assistant's
// discovery object-id alphabet: [A-Za-z0-9_-].
//
// Names that are already legal are returned unchanged, including case, so an
// existing Ev-Charger_1 entity is not duplicated as ev-charger_1 on upgrade.
// Names that need slugging keep a readable stem and append an FNV-1a tag of
// the original string so "Laddare, Garage" and "Laddare Garage" do not share
// a topic. The discovery `name` field still uses the human YAML name.
func mqttObjectID(name string) string {
if mqttIDLegal(name) {
return name
}
var b strings.Builder
lastSep := true
for _, r := range name {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
if ok {
b.WriteRune(r)
lastSep = r == '_' || r == '-'
continue
}
if !lastSep {
b.WriteByte('_')
lastSep = true
}
}
s := strings.Trim(b.String(), "_-")
if s == "" {
s = "driver"
}
return s + "_" + mqttNameTag(name)
}

func mqttIDLegal(name string) bool {
if name == "" {
return false
}
for _, r := range name {
ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_'
if !ok {
return false
}
}
return true
}

func mqttNameTag(name string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(name))
return fmt.Sprintf("%08x", h.Sum32())
}

func (b *Bridge) driverUniqueID(driver, suffix string) string {
return b.deviceID + "_" + mqttObjectID(driver) + suffix
}
Comment on lines +425 to +427

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep existing legal discovery IDs stable

For an existing driver whose name is already legal but contains uppercase characters, such as the test's Ev-Charger_1, this changes its unique_id and discovery topic on upgrade even though the declared allowed set includes uppercase. Because discovery configs are retained and the old topic is never removed, Home Assistant keeps the old entity while registering a new lowercase one, leaving users with duplicate and stale entities; preserve the original identifiers for already-legal names or explicitly migrate/remove the retained topics.

Useful? React with 👍 / 👎.


func (b *Bridge) driverDiscoveryTopic(kind, driver, object string) string {
return fmt.Sprintf("%s/%s/%s/%s_%s/config", b.discoPrefix, kind, b.deviceID, mqttObjectID(driver), mqttObjectID(object))
}

// ---- Autodiscovery ----
Expand Down Expand Up @@ -617,22 +679,21 @@ func (b *Bridge) publishDiscovery() {
} {
msg := b.withAvail(map[string]any{
"name": name + s.label,
"unique_id": b.deviceID + "_" + name + s.id,
"unique_id": b.driverUniqueID(name, s.id),
"state_topic": b.driverTopic(name, s.id),
"unit_of_measurement": s.unit,
"device_class": s.class,
"device": dev,
})
d, _ := json.Marshal(msg)
topic := fmt.Sprintf("%s/sensor/%s/%s_%s/config", b.discoPrefix, b.deviceID, name, s.id)
b.publish(topic, d, true)
b.publish(b.driverDiscoveryTopic("sensor", name, s.id), d, true)
}
total += 6

// Health binary_sensor: online/offline with JSON attributes for rich diagnostics.
healthMsg := b.withAvail(map[string]any{
"name": name + " Online",
"unique_id": b.deviceID + "_" + name + "_online",
"unique_id": b.driverUniqueID(name, "_online"),
"state_topic": b.driverTopic(name, "online"),
"json_attributes_topic": b.driverTopic(name, "health_json"),
"payload_on": "true",
Expand All @@ -642,7 +703,7 @@ func (b *Bridge) publishDiscovery() {
"device": dev,
})
d, _ := json.Marshal(healthMsg)
b.publish(fmt.Sprintf("%s/binary_sensor/%s/%s_online/config", b.discoPrefix, b.deviceID, name), d, true)
b.publish(b.driverDiscoveryTopic("binary_sensor", name, "online"), d, true)
total++

// Re-announce every live emit_metric sensor. Reading the snapshots here
Expand Down Expand Up @@ -676,10 +737,9 @@ func (b *Bridge) publishDiscovery() {
// Called when a metric is seen for the first time and on every reconnect.
func (b *Bridge) announceMetric(dev map[string]any, driver, metricName, explicitUnit string) {
unit, devClass := metricUnitAndClass(metricName, explicitUnit)
uid := b.deviceID + "_" + driver + "_" + metricName
msg := b.withAvail(map[string]any{
"name": driver + " " + strings.ReplaceAll(metricName, "_", " "),
"unique_id": uid,
"unique_id": b.driverUniqueID(driver, "_"+metricName),
"state_topic": b.driverTopic(driver, metricName),
"device": dev,
})
Expand All @@ -690,8 +750,7 @@ func (b *Bridge) announceMetric(dev map[string]any, driver, metricName, explicit
msg["device_class"] = devClass
}
d, _ := json.Marshal(msg)
topic := fmt.Sprintf("%s/sensor/%s/%s_%s/config", b.discoPrefix, b.deviceID, driver, metricName)
b.publish(topic, d, true)
b.publish(b.driverDiscoveryTopic("sensor", driver, metricName), d, true)
}

// metricUnitAndClass preserves a unit explicitly emitted by the driver and
Expand Down Expand Up @@ -867,7 +926,7 @@ func (b *Bridge) publishState() {
b.publishValue("ev_w", evW)
b.publishValue("v2x_w", v2xW)
b.publishValue("load_w", loadW)
b.publishValue("bat_soc_pct", avgSoC*100)
b.publishValue("bat_soc_pct", units.PercentFromFraction(avgSoC))
b.publishValue("grid_target_w", gridTarget)
b.publishValue("peak_limit_w", peakLimit)
b.publishValue("peak_import_ceiling_w", peakCeiling)
Expand Down Expand Up @@ -941,13 +1000,13 @@ func (b *Bridge) publishState() {
if r := b.tel.Get(name, telemetry.DerBattery); r != nil {
b.publishDriver(name, "bat_w", r.SmoothedW)
if r.SoC != nil {
b.publishDriver(name, "bat_soc_pct", *r.SoC*100)
b.publishDriver(name, "bat_soc_pct", units.PercentFromFraction(*r.SoC))
}
}
if r := b.tel.Get(name, telemetry.DerV2X); r != nil {
b.publishDriver(name, "v2x_w", r.SmoothedW)
if r.SoC != nil {
b.publishDriver(name, "v2x_vehicle_soc_pct", *r.SoC*100)
b.publishDriver(name, "v2x_vehicle_soc_pct", units.PercentFromFraction(*r.SoC))
}
}

Expand Down Expand Up @@ -1033,13 +1092,13 @@ func (b *Bridge) publishState() {
b.announceVehicleDriver(dev, name)
}
if r.SoC != nil {
b.publishDriver(name, "vehicle_soc_pct", *r.SoC*100)
b.publishDriver(name, "vehicle_soc_pct", units.PercentFromFraction(*r.SoC))
}
attrs := map[string]any{
"updated_at": r.UpdatedAt.UTC().Format(time.RFC3339),
}
if r.SoC != nil {
attrs["soc_pct"] = *r.SoC * 100
attrs["soc_pct"] = units.PercentFromFraction(*r.SoC)
}
if len(r.Data) > 0 {
var extra map[string]any
Expand All @@ -1059,7 +1118,7 @@ func (b *Bridge) publishState() {
func (b *Bridge) announceEVDriver(dev map[string]any, driver string) {
msg := b.withAvail(map[string]any{
"name": driver + " EV Power",
"unique_id": b.deviceID + "_" + driver + "_ev_w",
"unique_id": b.driverUniqueID(driver, "_ev_w"),
"state_topic": b.driverTopic(driver, "ev_w"),
"json_attributes_topic": b.driverTopic(driver, "ev_json"),
"unit_of_measurement": "W",
Expand All @@ -1068,14 +1127,14 @@ func (b *Bridge) announceEVDriver(dev map[string]any, driver string) {
"device": dev,
})
d, _ := json.Marshal(msg)
b.publish(fmt.Sprintf("%s/sensor/%s/%s_ev_w/config", b.discoPrefix, b.deviceID, driver), d, true)
b.publish(b.driverDiscoveryTopic("sensor", driver, "ev_w"), d, true)
}

// announceVehicleDriver publishes HA discovery for a vehicle SoC reader.
func (b *Bridge) announceVehicleDriver(dev map[string]any, driver string) {
msg := b.withAvail(map[string]any{
"name": driver + " Vehicle SoC",
"unique_id": b.deviceID + "_" + driver + "_vehicle_soc",
"unique_id": b.driverUniqueID(driver, "_vehicle_soc"),
"state_topic": b.driverTopic(driver, "vehicle_soc_pct"),
"json_attributes_topic": b.driverTopic(driver, "vehicle_json"),
"unit_of_measurement": "%",
Expand All @@ -1084,7 +1143,7 @@ func (b *Bridge) announceVehicleDriver(dev map[string]any, driver string) {
"device": dev,
})
d, _ := json.Marshal(msg)
b.publish(fmt.Sprintf("%s/sensor/%s/%s_vehicle_soc/config", b.discoPrefix, b.deviceID, driver), d, true)
b.publish(b.driverDiscoveryTopic("sensor", driver, "vehicle_soc"), d, true)
}

// publishPlan reads the current MPC plan and publishes:
Expand Down
Loading