Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 driver names so sensors with spaces or commas are not dropped, 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")
}
}
70 changes: 51 additions & 19 deletions go/internal/ha/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 +366,41 @@ 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 slugs a free-form YAML driver name into the character set
// Home Assistant allows in discovery object ids: [a-zA-Z0-9_-].
// "Laddare, Garage" becomes laddare_garage so EV sensors are not silently
// dropped. The human-readable name stays in the discovery `name` field.
func mqttObjectID(name string) string {
var b strings.Builder
lastSep := true
for _, r := range strings.ToLower(name) {
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_'

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 Make slugged driver identifiers collision-resistant

When two valid configured driver names normalize identically, such as Garage and garage or Laddare, Garage and Laddare Garage, this lossy slug assigns both drivers the same discovery IDs and state topics. Their Home Assistant entities overwrite one another and subsequent state publications become indistinguishable; include a stable collision-resistant component instead of using the slug as the identity.

Useful? React with 👍 / 👎.

if ok {
b.WriteRune(r)
lastSep = r == '_' || r == '-'
continue
}
if !lastSep {
b.WriteByte('_')
lastSep = true
}
}
s := strings.Trim(b.String(), "_-")
if s == "" {
return "driver"
}
return s
}

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 +652,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 +676,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 +710,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 +723,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 +899,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 +973,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 +1065,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 +1091,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 +1100,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 +1116,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
47 changes: 47 additions & 0 deletions go/internal/ha/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,50 @@ func TestStopAfterFailedConnectDoesNotDeadlock(t *testing.T) {
t.Fatal("Stop blocked after a failed Connect — teardown is stuck on <-doneCh; the connectAndStart rollback regressed")
}
}

func TestMQTTObjectIDSlugsIllegalDriverNames(t *testing.T) {
cases := []struct{ in, want string }{
{"easee", "easee"},
{"Laddare, Garage", "laddare_garage"},
{"Värmepump, källaren", "v_rmepump_k_llaren"},
{" foo bar ", "foo_bar"},
{"???", "driver"},
{"", "driver"},
{"Ev-Charger_1", "ev-charger_1"},
}
for _, c := range cases {
if got := mqttObjectID(c.in); got != c.want {
t.Errorf("mqttObjectID(%q) = %q, want %q", c.in, got, c.want)
}
}
}

func TestDiscoveryTopicsRejectIllegalCharacters(t *testing.T) {
b := &Bridge{deviceID: "site_box", discoPrefix: "homeassistant", topicPrefix: "ftw"}
topic := b.driverDiscoveryTopic("sensor", "Laddare, Garage", "ev_current_a")
want := "homeassistant/sensor/site_box/laddare_garage_ev_current_a/config"
if topic != want {
t.Fatalf("discovery topic = %q, want %q", topic, want)
}
for _, r := range topic {
if r == ',' || r == ' ' {
t.Fatalf("discovery topic still has illegal %q: %s", r, topic)
}
}
if got := b.driverTopic("Laddare, Garage", "ev_current_a"); got != "ftw/driver/laddare_garage/ev_current_a" {
t.Fatalf("state topic = %q", got)
}
if got := b.driverUniqueID("easee", "_ev_w"); got != "site_box_easee_ev_w" {
t.Fatalf("legal-name unique_id changed: %q", got)
}
}

func TestPercentFromFractionUsedForSoCDoor(t *testing.T) {
// Guards the HA *100 door against a NaN/overflow regression in units.
if unit, class := metricUnitAndClass("bat_soc_pct", ""); unit != "%" || class != "battery" {
t.Fatalf("bat_soc_pct class = (%q, %q)", unit, class)
}
if unit, class := metricUnitAndClass("pack_soc", ""); unit != "%" || class != "battery" {
t.Fatalf("_soc suffix class = (%q, %q)", unit, class)
}
}
Loading