Skip to content

feat(appproto): stream the grid's phase currents and the main fuse - #1629

Merged
frahlg merged 1 commit into
masterfrom
feat/grid-phases
Oct 14, 2026
Merged

frahlg merged 1 commit into
masterfrom
feat/grid-phases

Conversation

@frahlg

@frahlg frahlg commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

WP16 of the iPhone polish plan, step 2 of 3. On 14 October 2026 Fredrik asked to see the grid's phases, L1, L2 and L3, live against his main fuse. The box reads them every second since Sungrow 1.6.0 (#1619), but lane 0 and FTW Cloud's live body carried no phase. This sends them.

What changes

  • Registry. contract/registry.yaml gains extra_fields after the frozen set: grid_l1_da, grid_l2_da, grid_l3_da (11–13) and grid_fuse_da (14), all in tenths of an ampere. Copied byte for byte from feat(contract): the grid's phase currents and the main fuse as extra fields ftw-webapp#86, the source copy. gencontract reads the block into the same Fid type, adds FieldNames and FieldUnits for every id, keeps FrozenFieldNames for the frozen set alone, and refuses an id or a name that sits in both blocks, or an extra id among the frozen ones.
  • What goes out. Snapshot gains GridPhaseA and FuseA. fieldValues turns them into fields 11–14, so lane 0 and FTW Cloud's live body carry the same numbers. The dictionary points each phase at the site meter, so a phase is exactly as fresh as the meter. The fuse is config and points at no source. FTW Cloud's fieldSources names the meter for 11–13.
  • Which phases. Only the phases the site has (fuse.phases, in L1 to L3 order, as the fuse guard reads them), each only while the meter reports it, and none while the meter is offline. The fuse goes only with at least one phase: a scale with nothing on it says nothing.
  • Sign. Meters differ. Pixii signs its currents; Sungrow and the P1 meters give only their 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, gridPhaseAmps copies its sign onto the current's size. Otherwise the driver's own sign stands. This converts no vendor sign: it is math.Copysign(|a|, w), so a current the driver already signed keeps its sign and nothing can flip twice. A meter that gives neither sends positive values both ways; the registry says a positive current alone does not prove import.
  • A field that stops coming. A delta carries values, never an absence, so a phase the meter stopped giving would have stayed on the phone at its last value. Tick now resends the snapshot once when a field it sent is missing, the same way a new controlRev does. Both clients replace their whole field set on snap. This also covers a charger leaving field 10.
  • Lane 0 stays one 512-byte bucket. Four more integers fit with room to spare; a test fills every field with the largest values a home could show and gets one whole 512-byte delta. When a delta must shrink, fitDelta already drops the highest id first, so 11–14 go before the core view; a test proves the order.

Hardware evidence

The home box (v0.142.1-beta.1, Sungrow SH10RT as site meter, 20 A, three phases), read-only over ZeroTier, 14 October 2026 18:27:56–18:29:10. I have not run this branch on the box: that needs Fredrik's yes. Instead, 75 readings of /api/status (phase_amps, phase_powers, fuse), one a second, were fed through this branch's own code: a real appSite over a real telemetry.Store, a real appproto.Handler with hello and sub, Tick once per reading, and every frame it sent decoded back with appuplink.Codec(). The phone's view is the snapshot plus each delta.

  t s | box phase_amps A    | box phase_powers W | wire 11/12/13 dA  | fuse dA | lane 0
    0 |  3.73  6.01  2.89  |   -788   1568   -667 |   -37    60   -29 |     200 | tick 512 B
   10 |  3.69  6.87  2.88  |   -785   1535   -668 |   -37    69   -29 |     200 | delta 512 B
   20 |  3.71  7.62  2.86  |   -797   1326   -678 |   -37    76   -29 |     200 | delta 512 B
   30 |  3.79  7.66  2.93  |   -799   1560   -684 |   -38    77   -29 |     200 | delta 512 B
   40 |  3.78  7.38  2.94  |   -788   1544   -666 |   -38    74   -29 |     200 | delta 512 B
   50 |  3.68  5.87  2.84  |   -773   1373   -660 |   -37    59   -28 |     200 | delta 512 B
   60 |  3.47  5.85  2.87  |   -697   1337   -661 |   -35    59   -29 |     200 | delta 512 B
   70 |  3.47  7.36  2.88  |   -719   1321   -686 |   -35    74   -29 |     200 | delta 512 B
cloud live fields 11-14: -35 58 -30 200, field sources [sungrow]
samples 75 over 75 s, max |amp error| 0.050 A, signs 225/225 agree with phase_powers,
mismatches 0, lane 0 frame sizes map[512:75]

Every size is within half a tenth of the box's own, every sign matches the box's phase power, and all 75 lane 0 frames are 512 bytes. The battery was giving 3.4 kW through a three-phase inverter while the house drew on L2, so L1 and L3 sold while L2 bought: one phase near 7.7 A while the grid read near zero. The replay harness was a throwaway test file and is not in this branch.

FTW Cloud

An owner who shares live readings with FTW Cloud (ftw.live.read) now also shares the three phase currents and the fuse, at the same rate as the grid power. docs/protocol-cloud.md and the cloudwire.Live comment say so. FTW Cloud forwards the field map without reading ids, so the service needs no change.

Contract-pair: srcfl/ftw-webapp@feat/grid-phases

Paired pull requests. All three carry the same registry, byte for byte.

Merge this one and srcfl/ftw-webapp#86 together. The changeset is a patch: nothing changes for a user until the app draws the fields.

Not in this pull request

  • Sungrow sends 0 A for a failed read. sungrow.lua sets l1_a to 0 when register 5743 does not answer (optional_read gives nil), so a missed read looks like an idle phase rather than a missing one. Core's per-phase fuse guard reads the same 0. Leaving the keys out on a failed read belongs in srcfl/device-drivers.
  • The capability status.phases exists in the registry, and no box sends it. The app gates on the fields themselves, so this leaves it alone.

Tests

make verify                         vet + test + build clean
go test ./internal/appproto/...     TestPhasesAreTenthsOfAnAmpereOnTheWire, TestTheFuseTravelsOnlyWithPhases,
                                    TestThePhasesFollowTheGridMetersFreshness, TestEveryFieldAtOnceFitsTheBucket,
                                    TestTheExtraFieldsGiveWayFirst, TestAFieldThatStopsComingResendsTheSnapshot,
                                    TestGenerateRefusesFieldsThatCollide
go test ./cmd/ftw                   TestAppSnapshotSignsEachPhaseByItsPower, TestGridPhaseAmps (7 cases),
                                    TestAnOfflineSiteMeterSendsNoPhases, TestCloudLiveCarriesThePhasesUnderTheMeter
cd go/protocol && go test ./...     ok

🤖 Generated with Claude Code

Lane 0 and FTW Cloud's live body gain extra fields 11 to 14 from the
registry: the site meter's current on L1, L2 and L3 in tenths of an
ampere, signed by the site convention, and the main fuse per phase.
A meter that gives only the size of each current gets its sign from the
power on the same phase, which the driver already signed. Only the
site's phases go, and only while the meter is online. A field that
stops coming makes the box resend the snapshot once, because a delta
cannot carry an absence. The generator learns extra_fields and refuses
an id or name that means two things.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T16:49:26.802885Z aaf88b3 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aaf88b3201

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread go/cmd/ftw/app_link.go
Comment on lines +212 to +214
if w := watts[i]; w != nil && finite(*w) && *w != 0 {
a = math.Copysign(math.Abs(a), *w)
}

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 👍 / 👎.

@frahlg
frahlg merged commit 5c1feae into master Oct 14, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant