diff --git a/.changeset/pixii-pv-sunset.md b/.changeset/pixii-pv-sunset.md new file mode 100644 index 00000000..808238b5 --- /dev/null +++ b/.changeset/pixii-pv-sunset.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Update the bundled Pixii drivers. Fresh negative PV totals now report zero +generation instead of leaving solar stale or missing after sunset. The battery +driver also recovers when Pixii starts after FTW or returns after an outage. diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 811600f4..40d02e1f 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -16,7 +16,7 @@ "from the signed channel. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "afc9739dd19920246cadaa5135baa85485840528", + "commit": "76ddd5846f18cb1a0d53d85508904d4e953421b2", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/internal/drivers/pixii_driver_test.go b/go/internal/drivers/pixii_driver_test.go index a93c06f4..18419d12 100644 --- a/go/internal/drivers/pixii_driver_test.go +++ b/go/internal/drivers/pixii_driver_test.go @@ -308,7 +308,7 @@ func (m *gatedPixiiModbus) WriteMulti(addr uint16, values []uint16) error { func (m *gatedPixiiModbus) Close() error { return m.inner.Close() } -func TestPixiiRecoversAfterTransportGiveUp(t *testing.T) { +func TestPixiiRecoversAfterTransportOutage(t *testing.T) { path := filepath.Join("..", "..", "..", "drivers", "pixii.lua") tel := telemetry.NewStore() inner := newPixiiTestModbus() @@ -329,7 +329,8 @@ func TestPixiiRecoversAfterTransportGiveUp(t *testing.T) { if _, err := d.Poll(context.Background()); err != nil { t.Fatalf("live poll: %v", err) } - if tel.Get("pixii", telemetry.DerBattery) == nil { + before := tel.Get("pixii", telemetry.DerBattery) + if before == nil { t.Fatal("expected battery telemetry before the blip") } @@ -338,19 +339,25 @@ func TestPixiiRecoversAfterTransportGiveUp(t *testing.T) { if _, err := d.Poll(context.Background()); err == nil { t.Fatalf("outage poll %d succeeded", poll) } + if got := tel.Get("pixii", telemetry.DerBattery); got == nil || !got.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("outage poll %d refreshed battery telemetry: %+v", poll, got) + } } bus.setDown(false) if _, err := d.Poll(context.Background()); err != nil { t.Fatalf("recovery poll after Modbus returned: %v", err) } - if d.reprobes() != 1 { - t.Fatalf("reprobes = %d, want 1: pixii.lua gives up every register after 3 misses", d.reprobes()) + if d.reprobes() != 0 { + t.Fatalf("reprobes = %d, want 0: Pixii should recover without reloading the driver", d.reprobes()) } got := tel.Get("pixii", telemetry.DerBattery) if got == nil || got.SoC == nil || *got.SoC != 0.94 { t.Fatalf("recovered battery = %+v, want SoC 0.94", got) } + if !got.UpdatedAt.After(before.UpdatedAt) { + t.Fatal("recovery poll did not refresh battery telemetry") + } h := tel.DriverHealth("pixii") if h == nil || h.DeviceFault || !h.IsOnline() { t.Fatalf("expected online Pixii after the blip, got %+v", h) diff --git a/go/internal/drivers/pixii_pv_driver_test.go b/go/internal/drivers/pixii_pv_driver_test.go new file mode 100644 index 00000000..477dfbe2 --- /dev/null +++ b/go/internal/drivers/pixii_pv_driver_test.go @@ -0,0 +1,64 @@ +package drivers + +import ( + "context" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestPixiiPVSunsetReadingsStayFresh(t *testing.T) { + const name = "pixii-pv-test" + const topic = "pixii/status/test-pixii/meter_ext6" + tel := telemetry.NewStore() + mqtt := &fakeMQTT{} + d := newBundledMQTTDriver(t, "pixii_pv.lua", name, tel, mqtt, nil) + + steps := []struct { + name string + topic string + payload string + wantW float64 + wantUpdate bool + }{ + {"missing before first reading", topic, `{"meter_source":"PV","meter_w1":0,"meter_w2":0}`, 0, false}, + {"day", topic, `{"meter_source":"PV","meter_w1":1000,"meter_w2":2000,"meter_w3":3000}`, -6000, true}, + {"sunset", topic, `{"meter_source":"PV","meter_w1":-1,"meter_w2":-2,"meter_w3":-3}`, 0, true}, + {"night", topic, `{"meter_source":"PV","meter_w1":-1,"meter_w2":-2,"meter_w3":-3}`, 0, true}, + {"mixed phases", topic, `{"meter_source":"PV","meter_w1":50,"meter_w2":-3,"meter_w3":-2}`, -45, true}, + {"missing phase", topic, `{"meter_source":"PV","meter_w1":0,"meter_w2":0}`, -45, false}, + {"invalid phase", topic, `{"meter_source":"PV","meter_w1":"bad","meter_w2":0,"meter_w3":0}`, -45, false}, + {"idle", "", "", -45, false}, + {"grid only", "pixii/status/test-pixii/meter", `{"meter_w":-750}`, -45, false}, + {"morning", topic, `{"meter_source":"PV","meter_w1":500,"meter_w2":500,"meter_w3":500}`, -1500, true}, + } + for _, step := range steps { + before := tel.Get(name, telemetry.DerPV) + if step.topic != "" { + mqtt.Push(step.topic, step.payload) + } + if _, err := d.Poll(context.Background()); err != nil { + t.Fatalf("%s: poll: %v", step.name, err) + } + after := tel.Get(name, telemetry.DerPV) + if before == nil && !step.wantUpdate { + if after != nil { + t.Fatalf("%s: emitted PV without complete power: %+v", step.name, after) + } + continue + } + if after == nil || after.RawW != step.wantW { + t.Fatalf("%s: PV = %+v, want %.0f W", step.name, after, step.wantW) + } + if step.wantUpdate { + if before != nil && !after.UpdatedAt.After(before.UpdatedAt) { + t.Fatalf("%s: fresh MQTT did not refresh PV", step.name) + } + } else if !after.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("%s: PV refreshed without a valid PV reading", step.name) + } + } + if meter := tel.Get(name, telemetry.DerMeter); meter == nil || meter.RawW != -750 { + t.Fatalf("grid export = %+v, want -750 W", meter) + } +}