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/grid-phases-on-the-wire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

The box streams the grid's three phase currents to the app and to FTW Cloud, so the phone can show each phase against the main fuse. They go as extra fields 11–13 in tenths of an ampere, signed by the site convention, with the main fuse as field 14. A meter that gives only the size of each current, such as Sungrow, gets its sign from the power on the same phase. The box sends only the phases the site has, and only while the site meter is online. When a field stops coming, the box resends the whole snapshot once, so a phone never keeps a phase at its last value. Lane 0 keeps its 512-byte frame, and these fields are the first to give way when a delta does not fit.
23 changes: 23 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ frozen_fields:
# by the site convention: the charger consumes like any other load.
10: { name: ev_w, kind: power, unit: W }

# ---------------------------------------------------------------------------
# Field ids after the frozen set. They travel like the frozen ids, in lane
# 0's delta and in FTW Cloud's live body, under the same rules: integers,
# sent only while the box has the reading, and an id never changes meaning.
# They are depth for a node's own view, not the core view, so an app draws
# Now without them and a box may stop sending one. Lane 0 drops the highest
# id first when a delta does not fit its bucket, so these give way first.
#
# The grid's phase currents come from the site meter in tenths of an ampere,
# signed by the site convention: positive while the phase draws from the
# grid. A meter that gives magnitudes and no power per phase sends positive
# values both ways, so a positive current alone does not prove import. The
# box sends only the phases the site has, each while the meter is online and
# reports it, and their freshness is the grid meter's. grid_fuse_da is the
# main fuse per phase from the box's config, sent with the phases as their
# scale.
# ---------------------------------------------------------------------------
extra_fields:
11: { name: grid_l1_da, kind: current, unit: dA }
12: { name: grid_l2_da, kind: current, unit: dA }
13: { name: grid_l3_da, kind: current, unit: dA }
14: { name: grid_fuse_da, kind: current, unit: dA }

# ---------------------------------------------------------------------------
# Capabilities. A set of names, not version numbers. Absent means the box does
# not have it; the UI hides the feature and never crashes.
Expand Down
2 changes: 1 addition & 1 deletion docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ list still show what kinds of equipment a site has.

| Field | Type | Meaning |
|---|---|---|
| `fields` | map text→int | Frozen field ids 1–6 and 10, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W |
| `fields` | map text→int | Frozen field ids 1–6 and 10, and the extra fields 11–14 when the site meter gives phases, keyed and encoded as the app protocol's `delta` does: field 1 is the mode index, power in W, phase currents and the main fuse in tenths of an ampere |
| `fieldSources` | map text→[text] | The sources each field's freshness follows. Sent only with `sources` |
| `sources` | map text→Source | The source table. Sent whenever `srcRev` changes, and in every full message |
| `srcRev` | uint | Rises by 1, per boot, each time the source table changes |
Expand Down
53 changes: 52 additions & 1 deletion go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package main

import (
"context"
"encoding/json"
"errors"
"log/slog"
"math"
"net/http"
"sync"
"sync/atomic"
Expand Down Expand Up @@ -59,9 +61,10 @@ func (a *appSite) Snapshot() appproto.Snapshot {
mode := a.ctrl.Mode
siteMeterDriver := a.ctrl.SiteMeterDriver
rev := a.revision.Observe(a.ctrl)
fuseA, fusePhases := a.ctrl.SiteFuseAmps, a.ctrl.SiteFusePhases
a.ctrlMu.Unlock()

snap := appproto.Snapshot{Mode: mode, ControlRev: rev}
snap := appproto.Snapshot{Mode: mode, ControlRev: rev, FuseA: fuseA}

uptimeMs := time.Since(a.started).Milliseconds()
seen := map[string]bool{}
Expand Down Expand Up @@ -97,6 +100,7 @@ func (a *appSite) Snapshot() appproto.Snapshot {
if health := a.tel.DriverHealth(siteMeterDriver); health != nil && health.IsOnline() {
if reading := a.tel.Get(siteMeterDriver, telemetry.DerMeter); reading != nil {
snap.GridW = reading.SmoothedW
snap.GridPhaseA = gridPhaseAmps(reading, fusePhases)
}
} else {
// The invariant is that stale site-meter data stops dispatch.
Expand Down Expand Up @@ -166,6 +170,53 @@ func (a *appSite) Snapshot() appproto.Snapshot {
return snap
}

// gridPhaseAmps reads the site meter's current on each of the site's phases
// and gives it the site convention's sign.
//
// Meters differ here. Some sign the current; many, Sungrow among them, give
// only its size. The power on the same phase already carries the site's
// sign, because the driver converted it, so where the meter reports that
// power its sign is the phase's direction. Otherwise the driver's own sign
// stands. This converts no vendor sign: it copies a site sign onto a size,
// and a current the driver already signed keeps its sign.
//
// Only the site's phases are read, in L1 to L3 order, as the fuse guard
// reads them: a one-phase site owns no L2, whatever the payload holds.
func gridPhaseAmps(r *telemetry.DerReading, phases int) [3]*float64 {
var out [3]*float64
if r == nil || len(r.Data) == 0 {
return out
}
var d struct {
L1A *float64 `json:"l1_a"`
L2A *float64 `json:"l2_a"`
L3A *float64 `json:"l3_a"`
L1W *float64 `json:"l1_w"`
L2W *float64 `json:"l2_w"`
L3W *float64 `json:"l3_w"`
}
if err := json.Unmarshal(r.Data, &d); err != nil {
return out
}
if phases <= 0 || phases > len(out) {
phases = len(out)
}
amps := [3]*float64{d.L1A, d.L2A, d.L3A}
watts := [3]*float64{d.L1W, d.L2W, d.L3W}
finite := func(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) }
for i := 0; i < phases; i++ {
if amps[i] == nil || !finite(*amps[i]) {
continue
}
a := *amps[i]
if w := watts[i]; w != nil && finite(*w) && *w != 0 {
a = math.Copysign(math.Abs(a), *w)
}
Comment on lines +212 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize phase-current signs in the driver

When a driver already supplies signed l*_a and its nonzero l*_w has the opposite sign, this discards the driver's current sign and reports the opposite direction to both the app and FTW Cloud. Preserve the normalized driver value here and fix magnitude-only vendors at their driver boundary instead; Core-side Copysign violates the project's explicit sign-conversion invariant.

AGENTS.md reference: AGENTS.md:L63-L64

Useful? React with 👍 / 👎.

out[i] = &a
}
return out
}

// appBoxInfo is what the box calls itself.
type appBoxInfo struct {
id string
Expand Down
125 changes: 125 additions & 0 deletions go/cmd/ftw/app_link_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"math"
"path/filepath"
"sync"
Expand Down Expand Up @@ -344,3 +345,127 @@ func TestAppLoadpointsFlipSurplusOnlyThroughTheManager(t *testing.T) {
t.Fatal("a loadpoint the box does not have took a flag")
}
}

// The home box's Sungrow, read on 14 October 2026: it gives each phase's
// current as a size and each phase's power with the site's sign. One phase
// imported while the other two exported, so the wire must say which is
// which, and the fuse travels with them as their scale.
func TestAppSnapshotSignsEachPhaseByItsPower(t *testing.T) {
tel, ctrl := seedSite(t)
tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage(
`{"w":160,"l1_w":-367,"l2_w":908,"l3_w":-381,"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`))
ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3
site := &appSite{
tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{},
revision: &control.Revision{}, started: time.Now(),
siteMeterStale: time.Minute,
}

snap := site.Snapshot()

want := [3]float64{-2.13, 4.76, -1.61}
for i, w := range want {
if snap.GridPhaseA[i] == nil || *snap.GridPhaseA[i] != w {
t.Fatalf("L%d = %v, want %v", i+1, snap.GridPhaseA[i], w)
}
}
if snap.FuseA != 20 {
t.Fatalf("fuse = %v, want 20", snap.FuseA)
}
}

func TestGridPhaseAmps(t *testing.T) {
reading := func(data string) *telemetry.DerReading {
return &telemetry.DerReading{Data: json.RawMessage(data)}
}
show := func(p [3]*float64) []any {
out := make([]any, 3)
for i, v := range p {
if v != nil {
out[i] = *v
}
}
return out
}
cases := []struct {
name string
data string
phases int
want []any
}{
{
// A meter that signs its currents and gives no power keeps them.
name: "signed by the driver", phases: 3,
data: `{"l1_a":-17,"l2_a":3,"l3_a":0}`,
want: []any{-17.0, 3.0, 0.0},
},
{
// A P1 meter: sizes only. Positive is all it can say.
name: "sizes only", phases: 3,
data: `{"l1_a":4.2,"l2_a":0.4,"l3_a":1.1}`,
want: []any{4.2, 0.4, 1.1},
},
{
// The power is the site's sign, set by the driver. Copying it
// onto a current already signed the same way changes nothing.
name: "power agrees with a signed current", phases: 3,
data: `{"l1_a":-6,"l1_w":-1380,"l2_a":2,"l2_w":460,"l3_a":1,"l3_w":0}`,
want: []any{-6.0, 2.0, 1.0},
},
{
// A one-phase site owns no L2, whatever the payload holds.
name: "one phase", phases: 1,
data: `{"l1_a":9.5,"l1_w":2185,"l2_a":3,"l3_a":3}`,
want: []any{9.5, nil, nil},
},
{
name: "a phase the meter does not report", phases: 3,
data: `{"l1_a":1,"l3_a":2}`,
want: []any{1.0, nil, 2.0},
},
{
// Harnesses that leave fuse.phases unset read three, as the
// fuse guard does.
name: "phases unset", phases: 0,
data: `{"l1_a":1,"l2_a":2,"l3_a":3}`,
want: []any{1.0, 2.0, 3.0},
},
{
name: "no phases at all", phases: 3,
data: `{"w":1200}`,
want: []any{nil, nil, nil},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := show(gridPhaseAmps(reading(c.data), c.phases))
for i := range c.want {
if got[i] != c.want[i] {
t.Fatalf("L%d = %v, want %v (all: %v)", i+1, got[i], c.want[i], got)
}
}
})
}
if got := gridPhaseAmps(nil, 3); got != [3]*float64{} {
t.Fatalf("no reading gave %v", show(got))
}
}

// An offline meter's phases are as old as its power. The power already goes
// unsent; the phases go with it, so the app is never handed a phase it would
// have to age by itself.
func TestAnOfflineSiteMeterSendsNoPhases(t *testing.T) {
tel, ctrl := seedSite(t)
tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage(`{"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`))
tel.DriverHealthMut("meter").SetOffline()
ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3
site := &appSite{
tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{},
revision: &control.Revision{}, started: time.Now(),
siteMeterStale: time.Minute,
}

if got := site.Snapshot().GridPhaseA; got != [3]*float64{} {
t.Fatalf("an offline meter sent phases %v", got)
}
}
6 changes: 5 additions & 1 deletion go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ func (c *cloudSite) Live() cloudlink.Live {
}

// Each field follows the drivers that produce it. Load is what is left
// of the grid after every other flow, so it follows all of them.
// of the grid after every other flow, so it follows all of them. The
// fuse is config and follows none.
meter := siteMeterName(c.site.ctrl, c.site.ctrlMu)
byType := func(t telemetry.DerType) []string {
var names []string
Expand All @@ -95,6 +96,9 @@ func (c *cloudSite) Live() cloudlink.Live {
meterOnly = []string{meter}
}
set(appproto.FidGridW, meterOnly)
set(appproto.FidGridL1Da, meterOnly)
set(appproto.FidGridL2Da, meterOnly)
set(appproto.FidGridL3Da, meterOnly)
set(appproto.FidPvW, pv)
set(appproto.FidBatteryW, battery)
set(appproto.FidBatterySoc, battery)
Expand Down
41 changes: 41 additions & 0 deletions go/cmd/ftw/cloud_link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"io"
"log/slog"
"path/filepath"
"slices"
"strconv"
"sync"
"testing"
"time"

"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

Expand Down Expand Up @@ -158,3 +163,39 @@ func TestAppSessionsHearOfFTWCloudOnlyWhenItCanRun(t *testing.T) {
}
}
}

// FTW Cloud's live body is the same field set the app session streams, and
// each phase follows the site meter's freshness there too. The fuse is
// config, so it names no driver.
func TestCloudLiveCarriesThePhasesUnderTheMeter(t *testing.T) {
tel, ctrl := seedSite(t)
tel.Update("meter", telemetry.DerMeter, 160, nil, json.RawMessage(
`{"l1_w":-367,"l2_w":908,"l3_w":-381,"l1_a":2.13,"l2_a":4.76,"l3_a":1.61}`))
ctrl.SiteFuseAmps, ctrl.SiteFusePhases = 20, 3
c := &cloudSite{site: &appSite{
tel: tel, ctrl: ctrl, ctrlMu: &sync.Mutex{},
revision: &control.Revision{}, started: time.Now(),
siteMeterStale: time.Minute,
}}

live := c.Live()

want := map[appproto.Fid]int64{
appproto.FidGridL1Da: -21, appproto.FidGridL2Da: 48, appproto.FidGridL3Da: -16, appproto.FidGridFuseDa: 200,
}
for fid, v := range want {
key := strconv.Itoa(int(fid))
if got, ok := live.Fields[key]; !ok || got != v {
t.Fatalf("field %s = %d (present %v), want %d", key, got, ok, v)
}
}
for _, fid := range []appproto.Fid{appproto.FidGridL1Da, appproto.FidGridL2Da, appproto.FidGridL3Da} {
key := strconv.Itoa(int(fid))
if got := live.FieldSources[key]; !slices.Equal(got, []string{"meter"}) {
t.Fatalf("field %s follows %v, want the meter", key, got)
}
}
if got, ok := live.FieldSources[strconv.Itoa(int(appproto.FidGridFuseDa))]; ok {
t.Fatalf("the fuse follows %v; it is config", got)
}
}
Loading
Loading