flux branch T-1274/bugfix-late-night-current-data-gap commits 5 files 19 touched lines +959 / -11

Pre-push review: T-1274 late-night current-data gap

Overnight AlphaESS outage caused the iOS Dashboard to render 0% / 0 W everywhere. Layered fix at the client, poller, API, and a one-off backfill CLI for the rows already corrupted.

At a glance

  • Bug root cause: AlphaESS returned code:200 with data:null overnight; json.Unmarshal silently turned it into zero-valued PowerData, which the poller wrote every 10 s.
  • Layer 1internal/alphaess/client.go: GetLastPowerData rejects null/empty Data with a typed error.
  • Layer 2internal/poller/poller.go: isAllZeroPower skips persistence and logs raw values at warn for CloudWatch diagnosis.
  • Layer 3internal/api/status.go: drops Live + cutoff times when the latest stored reading is older than 90 s (liveDataStalenessThreshold).
  • Layer 4cmd/backfill-readings: one-off CLI that deletes the bogus zero rows and writes synthetic 5-min readings from getOneDayPowerBySn.
  • Refactor — new alphaess.DerivePower pins the pgrid/pbat sign convention so mapDailyPowerToPoints and NewReadingItemFromSnapshot can't drift.

Verdict

Ready to push

All 10 findings from the parallel reuse / quality / efficiency / spec agents addressed or explicitly skipped with rationale. Full Go suite green except the unrelated pre-existing TestLoad_MissingRequiredVars/AWS_REGION shell-leakage flake (reproducible on main). make lint + go vet ./... clean. Backfill CLI go builds.

Review findings

10 raised · 7 fixed · 3 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The dashboard was showing "0% / 0 W everywhere" overnight instead of real values. AlphaESS (the manufacturer's cloud) was sending empty responses around 11 PM and our code was silently storing those as real "everything is zero" readings.

Why it matters

A dashboard that lies once loses trust forever. "0% battery, nothing happening" implies the system is dying or about to lose power, when actually the battery is at 65% and the house is humming along normally.

Key concepts

  • Poller: the Go program in AWS that asks AlphaESS for current values every 10 seconds.
  • flux-readings: the DynamoDB table where those values land. The dashboard reads the most recent row.
  • Three layers + repair tool: we now refuse to receive empty responses, refuse to store all-zero rows, refuse to serve stale data as live, and have a tool to repair rows already corrupted.

Architecture

Layered defence — each layer of the data pipeline refuses bad input so the corruption can't propagate downward:

  1. Client (alphaess.GetLastPowerData): checks the raw json.RawMessage for null or empty before unmarshalling. A json.Unmarshal([]byte("null"), &result) silently produces a zero-valued struct — that was the root cause.
  2. Poller (fetchAndStoreLiveData): mirrors the existing isAllZeroEnergy pattern with isAllZeroPower. Logs the raw ppv/pload/pbat/pgrid/soc at warn level for CloudWatch.
  3. API (handleStatus): a single liveFresh boolean (90 s threshold) gates Live, battery.EstimatedCutoff, and rolling15min.EstimatedCutoff. low24h and rolling averages still derive from their own windowed subsets and remain correct independently.
  4. Backfill (cmd/backfill-readings): per Sydney date, queries existing readings, deletes IsAllZeroReading rows via BatchWriteItem DeleteRequest, fetches getOneDayPowerBySn, writes synthetic 5-min ReadingItems via BatchWriteItem PutRequest.

Patterns

  • Shared sign convention: new alphaess.DerivePower(load, ppv, gridCharge, feedIn) centralises the pgrid/pbat formula. Both mapDailyPowerToPoints (Day Detail past-date fallback) and NewReadingItemFromSnapshot (backfill) call it, so the convention can drift in only one place.
  • Sibling helpers, not generic: isAllZeroPower, isAllZeroEnergy, IsAllZeroReading live in three packages on three types. A unifying interface would need five method receivers and uglier code; three concrete one-liners stay.

Trade-offs

  • Server-side staleness vs client-side live.timestamp: server-side wins — one line of Go covers iOS, macOS, and widgets without per-client coordination.
  • Drop Live vs add a stale flag: dropping uses the existing "Awaiting live data" UI with zero client changes.
  • CLI talks to DynamoDB directly vs through the Store interface: keeping it direct keeps the production interface narrow for a one-off tool.

Deep dive

Null detection in the client. AlphaESS envelope is {code, msg, data: json.RawMessage}. doGet already gates on code ∈ {0,200}. New check: len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("null")). TrimSpace handles whitespace flavours of null; len==0 covers the field being omitted entirely (RawMessage renders that as nil). Scoped to GetLastPowerData only — other endpoints have endpoint-specific empty-data semantics (getOneDayPowerBySn legitimately returns [] for empty days).

Staleness threshold rationale. 90 s = nine missed 10 s polls. Tighter (30 s) flickers on a single AlphaESS slow-response (10 s timeout + retry). Looser (5 min) hides real outages too long. Constant is time.Duration to match livePollInterval, dailyPowerInterval, etc. The liveFresh boolean is computed once and reused at three guard sites — Live, battery.EstimatedCutoff, rolling15min.EstimatedCutoff — all of which depend on the latest reading's instantaneous state. Time-windowed aggregates (low24h, Rolling15m averages) keep their own gating because they remain meaningful when the latest is stale.

Backfill TTL semantics. NewReadingItemFromSnapshot derives TTL from now, not the snapshot's uploadTime. Two-day-old snapshots backfilled today get the full 30-day TTL starting now; the alternative would have older rows expire almost immediately and the gap re-emerge.

Architecture impact

  • Store interface stays narrow; the CLI deliberately uses the raw *dynamodb.Client. Widening Store for a one-off repair tool would couple unrelated lifecycles.
  • alphaess.DerivePower is now the canonical chokepoint for the snapshot→reading sign convention. Any future change to feedIn/gridCharge orientation has one place to land.
  • Warn-level structured logs in the live poll path are new. In a sustained outage that's ~6 warns/minute, ~360/hour — negligible CloudWatch cost, but future alerting needs to handle the burstiness.

Edge cases & potential issues

  • 90 s threshold is a tuning knob. If AlphaESS introduces a higher-latency response mode, or the poll cadence changes, the threshold needs to follow.
  • getOneDayPowerBySn for today stops at the latest 5-min boundary. Backfill+live readings coexist (different timestamps); downsample buckets weight the synthetic 1/31 against ~30 live readings — invisible smoothing.
  • IsAllZeroReading depends on the inverter's 5% min-discharge config. If that's ever dropped to 0%, the helper could delete genuine readings at the moment the battery sat at 0%. Captured in Decision 2.
  • No CloudWatch metric for skipped live writes. Diagnostic is observability, not alarming — a follow-up to add a LiveWriteSkipped counter is left for if sustained outages become recurring.

Important changes — detailed

Client refuses null/empty AlphaESS payload

internal/alphaess/client.go

Why it matters. Direct root cause closure. Before this, json.Unmarshal of a JSON null silently produced a zero PowerData that the poller wrote as a real reading — exactly the failure that made the dashboard show 0% / 0 W overnight.

What to look at. internal/alphaess/client.go:104-123 (GetLastPowerData + isNullJSON)

Takeaway. Raw json.RawMessage needs explicit null/empty checks when downstream code relies on json.Unmarshal returning an error to signal no-data. Unmarshalling into a struct happily turns null into zero values.
Rationale. Closes the bug at the source layer so neither the poller nor /status has to know about the AlphaESS null-payload behaviour. The check is scoped to GetLastPowerData because that's the endpoint with this specific failure mode — other endpoints have endpoint-specific empty-data semantics (getOneDayPowerBySn legitimately returns [] for empty days).

Poller skips all-zero readings + logs raw values

internal/poller/poller.go

Why it matters. Catches the structurally-valid-but-all-zero variant the client layer can't see, and produces the actionable diagnostic log the user explicitly asked for ('why can't it collect the live data?').

What to look at. internal/poller/poller.go:180-213 (isAllZeroPower + the warn log)

Takeaway. Sibling helpers across packages don't always need to be unified through an interface — three one-line isAllZero functions on three concrete types is cleaner than introducing a Zeroable abstraction with five method receivers.
Rationale. Mirrors the existing isAllZeroEnergy pattern so any maintainer already knows the shape. The structured warn log includes the raw ppv/pload/pbat/pgrid/soc so a future investigator can confirm exactly what AlphaESS sent without enabling a debug flag.

Status drops Live when latest reading is stale

internal/api/status.go

Why it matters. Defence in depth. Even if a future failure mode slips past layers 1 and 2, /status still refuses to surface aged readings as live, so the dashboard's existing 'Awaiting live data' UI tells the user the truth.

What to look at. internal/api/status.go:14-22 (constant), 78-86 (Live gate), 117 (battery cutoff), 153 (rolling cutoff)

Takeaway. When a single bool gates multiple downstream blocks, compute it once and reuse — both for readability and to make sure new blocks added later don't accidentally diverge from the same logical condition.
Rationale. 90 s = nine missed 10 s polls. Tight enough to surface real outages within two dashboard refresh cycles, loose enough to absorb single AlphaESS slow-response retries. Captured in Decision 1 of the decision log.

Backfill CLI repairs the bogus rows already in DynamoDB

cmd/backfill-readings/main.go

Why it matters. The forward fixes prevent new corruption but the existing zero rows from last night's outage corrupt low24h and the Day Detail today chart for 30 days (TTL). This tool is how the user actually gets real data back.

What to look at. cmd/backfill-readings/main.go:120-218 (per-day pipeline)

Takeaway. BatchWriteItem with mixed DeleteRequest + PutRequest patterns can be split into two passes for clarity without a meaningful perf cost — the bottleneck is the upstream API call, not the DynamoDB round-trips.
Rationale. Synthetic 5-minute readings derived from getOneDayPowerBySn are good enough to recover low24h and the Day Detail chart. The TTL is computed from now (not the snapshot's age) so backfilled rows get a full 30-day lease.

Shared sign convention via alphaess.DerivePower

internal/alphaess/models.go

Why it matters. The pgrid/pbat formula was inlined in both mapDailyPowerToPoints and NewReadingItemFromSnapshot. If the sign convention ever shifts, having one chokepoint prevents the two from drifting into disagreement — a silent correctness bug that wouldn't show up in tests targeted at either consumer alone.

What to look at. internal/alphaess/models.go:42-52 (DerivePower) + the two call sites updated

Takeaway. When the same arithmetic appears in multiple consumers of related (but not identical) types, lifting it to take raw scalar params lets both call sites use it without forcing them through a shared type.
Rationale. Discovered during the pre-push review (Agent 1 / code reuse). The duplication was small in LoC but high in correctness risk because both sites embed the same sign convention; getting them out of sync would corrupt past-date Day Detail and the backfill simultaneously.

Doc correction: getOneDayPowerBySn power fields are not zero

docs/flux-v1.md

Why it matters. The previous doc claim 'Power fields return 0 — only cbat is usable' contradicted both mapDailyPowerToPoints (which has used those fields successfully for past-date Day Detail since release) and the new backfill (which depends on them). The contradiction would mislead any future contributor into rejecting fallback approaches that actually work.

What to look at. docs/flux-v1.md:96 (table row)

Takeaway. Doc claims about API behaviour age poorly. When a fix depends on behaviour the docs disclaim, verify against actual usage rather than the doc and update the doc to match observed reality.
Rationale. Spotted by Agent 4 (spec & docs review). The previous comment was probably accurate at some early point but had drifted out of sync with the code's actual use of those fields.

Key decisions

Layer the fix at client, poller, and API.

Each layer's failure mode is distinct: the client sees a structurally-empty response, the poller sees a structurally-valid all-zero response, the API sees aged rows. Catching at each layer means no single mistake can re-introduce the corruption end-to-end.

Server-side staleness gate, not per-client.

The live.timestamp field is in the wire response already and unused. iOS / macOS / widgets would each need to reimplement the same logic; the widget already uses a different staleness concept (fetch time, not data time). One Go line in /status covers all clients without coordination.

90 s staleness threshold.

Nine missed 10 s polls. See decision_log.md Decision 1 for trade-off vs 30 s (too flickery) and 5 min (hides real outages too long).

Drop <code>Live</code> entirely rather than add a stale flag.

Smaller wire-format change, zero client updates needed, existing 'Awaiting live data' UI conveys exactly the right meaning.

Delete every-field-zero rows in the backfill unconditionally.

A running battery system cannot legitimately produce Soc=0 AND every power field=0 because the inverter is gated at 5% min discharge. Same logic as the existing isAllZeroEnergy for daily energy. Captured in decision_log.md Decision 2.

CLI uses the raw DynamoDB client, not the <code>Store</code> interface.

Going through Store would require adding plural WriteReadings/DeleteReadings methods to the production interface for a single one-off caller. The LogStore dry-run would also produce per-item noise instead of the current per-day plan summary. Keep the production interface narrow.

Three sibling <code>isAllZero*</code> functions instead of an interface.

isAllZeroPower, isAllZeroEnergy, IsAllZeroReading operate on three types in three packages. A unifying Zeroable interface would need five method receivers across the codebase — uglier than the duplication.

Synthetic readings get TTL from <code>now</code>, not the snapshot's age.

Backfilling a 2-day-old snapshot today produces a row that survives the full 30 days. The alternative (TTL from uploadTime) would have older backfilled rows expire almost immediately and the gap re-emerge.

Review findings

SeverityAreaFindingResolution
minorinternal/api/status.go<code>liveDataStalenessThresholdSec</code> was declared as <code>int</code> with a <code>Sec</code> suffix, diverging from every other timing constant in the codebase (which use <code>time.Duration</code>).Renamed to <code>liveDataStalenessThreshold</code> with type <code>time.Duration = 90 * time.Second</code>. The single comparison site converts via <code>int64(threshold.Seconds())</code> to compare against the Unix-int reading timestamps.
minorcmd/backfill-readings/main.go<code>batchDeleteReadings</code> used an anonymous-struct + <code>attributevalue.MarshalMap</code> dance purely to build a 2-field key. The rest of the codebase (<code>dynamostore.go</code>, <code>notes.go</code>) builds delete keys as direct <code>map[string]types.AttributeValue</code>.Replaced with the direct <code>map[string]types.AttributeValue</code> idiom matching the codebase. Drops a marshal error path that couldn't actually trigger.
majorinternal/alphaess/models.go / internal/dynamo/models.go / internal/api/day.goThe pgrid/pbat sign convention (<code>pgrid = gridCharge − feedIn; pbat = load − ppv − pgrid</code>) was inlined in both <code>mapDailyPowerToPoints</code> (past-date Day Detail) and <code>NewReadingItemFromSnapshot</code> (backfill). Future changes to the convention could silently drift between the two consumers and corrupt both surfaces simultaneously.Extracted <code>alphaess.DerivePower(load, ppv, gridCharge, feedIn) (pgrid, pbat)</code>. Both consumers now call it. Tests in <code>internal/alphaess/models_test.go</code> exercise importing / exporting / idle / solar-covers-load.
majordocs/flux-v1.mdThe <code>getOneDayPowerBySn</code> row claimed 'Power fields return 0 — only cbat is usable', which contradicted both the existing past-date Day Detail fallback (<code>mapDailyPowerToPoints</code>) and the new backfill (both depend on those fields). The 'Alternatives considered' section in the report repeated the same incorrect claim.Updated the doc row to describe what the snapshot actually carries and how pgrid/pbat are reconstructed. Reworded the report's rejected alternative so the real reason (hourly poll cadence = stale-by-up-to-an-hour for live fallback) is recorded instead of the incorrect 'fields unreliable' rationale.
minordocs/agent-notes/{poller-orchestrator,api-layer,dynamo-layer}.mdAgent notes didn't mention <code>isAllZeroPower</code>, <code>liveDataStalenessThreshold</code>, or the new <code>NewReadingItemFromSnapshot</code> / <code>IsAllZeroReading</code> helpers.Added bullets to each note documenting the new behaviour and where to find each helper.
minorspecs/bugfixes/late-night-current-data-gap/report.md'Changes made' list was missing three production files (<code>internal/dynamo/models.go</code>, <code>cmd/backfill-readings/main.go</code>, <code>internal/api/day.go</code>) plus their tests; only the Affected Files table at the bottom listed everything.Split 'Changes made' into 'Defence-in-depth fixes' and 'Backfill tooling' sections and pulled in every file.
minorspecs/bugfixes/late-night-current-data-gap/The two arbitrary numeric/policy choices in the PR (90 s threshold; deleting every-field-zero rows) deserved explicit decision-log entries so future maintainers don't have to re-derive the rationale.Added <code>decision_log.md</code> with Decision 1 (90 s threshold trade-off vs 30 s / 5 min / client-side alternatives) and Decision 2 (all-zero deletion assumption + dependency on the inverter's 5% min-discharge config).
minorcmd/backfill-readings/main.go vs internal/dynamo/dynamostore.goReuse agent flagged that the CLI reimplements BatchWriteItem chunking + retry logic that already exists in <code>WriteDailyPower</code>. Could be unified through new <code>Store</code> methods.Skipped after weighing the trade-off. Adding <code>WriteReadings</code>/<code>DeleteReadings</code> to <code>Store</code> for a single one-off caller widens the production interface, and the <code>LogStore</code> dry-run would replace the current per-day plan summary with per-item noise — worse UX. Decision recorded in the commit message and PR description.
minorinternal/api/status.goEfficiency agent noted <code>latest</code> is re-extracted via <code>allReadings[len(allReadings)-1]</code> three times in <code>handleStatus</code>.Skipped — micro-readability only, zero perf impact (slice index is O(1), ReadingItem is a small struct). Would need careful threading of an <code>Optional[ReadingItem]</code> shape and adds complexity that doesn't pay for itself.
minorinternal/dynamo/models.goQuality agent suggested pushing <code>UploadTime</code> parsing upstream into the AlphaESS client so <code>NewReadingItemFromSnapshot</code> could drop the <code>loc</code> parameter.Skipped — bigger scope than warranted for this PR; touches the client decode path and every other consumer of <code>PowerSnapshot.UploadTime</code>. Worth doing as a separate cleanup if a third consumer appears.

Per-file diffs

Click to expand.

CHANGELOG.md Modified user-facing
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6129985..96c86a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

 ## [Unreleased]

+### Fixed
+
+- **Dashboard no longer shows "0% / 0 W everywhere" overnight** (T-1274). When AlphaESS goes quiet at night (`getLastPowerData` returns `code:200` with `data: null` or an all-zero object), the poller previously dutifully unmarshalled the missing payload into a zero-valued `PowerData` and wrote a fresh `ReadingItem` with every field zero. `/status` then surfaced those zeros as the live readout, so the dashboard claimed SoC was 0% and nothing was running until backfill caught up. Three reinforcing fixes: (a) `GetLastPowerData` now treats null/empty `data` as an error so the poller logs and skips it; (b) the poller's `fetchAndStoreLiveData` refuses to persist every-field-zero readings and logs the raw values at warn level so the overnight AlphaESS behaviour is visible in CloudWatch; (c) `/status` drops `live` (and the cutoff times derived from it on `battery` and `rolling15min`) when the most recent stored reading is older than 90 s, so the dashboard's existing "Awaiting live data" state surfaces instead of holding aged numbers.
+
+### Added
+
+- **`cmd/backfill-readings` CLI** (T-1274). One-off tool that, for a date range, removes the all-zero `flux-readings` rows the overnight AlphaESS outage produced and replaces them with synthetic 5-minute readings derived from `getOneDayPowerBySn` snapshots (same field mapping the Day Detail past-date fallback uses: `cbat → soc`, `gridCharge − feedIn → pgrid`, `load − ppv − pgrid → pbat`). Supports `--dry-run`; defaults to the trailing 3 days.
+
 ## [1.2] - 2026-05-13

 ### Added
cmd/backfill-readings/main.go New +319 / -0
diff --git a/cmd/backfill-readings/main.go b/cmd/backfill-readings/main.go
new file mode 100644
index 0000000..88e242c
--- /dev/null
+++ b/cmd/backfill-readings/main.go
@@ -0,0 +1,316 @@
+// Package main is a one-off CLI that repairs flux-readings rows corrupted by
+// the T-1274 overnight outage.
+//
+// When AlphaESS started returning `code: 200` with `data: null` for
+// `getLastPowerData`, the poller silently unmarshalled the missing payload
+// into a zero-valued PowerData and wrote a fresh ReadingItem per 10 s with
+// every field zero. Those rows then drove the iOS Dashboard to render 0%
+// SoC / 0 W everywhere, and they corrupt the Day Detail "today" chart and
+// the `low24h` calculation.
+//
+// This tool, for each Sydney-local date in the given range:
+//
+//  1. Queries the day's existing readings from flux-readings.
+//  2. Deletes the all-zero ones (every field exactly 0, the bogus pattern).
+//  3. Fetches the 5-minute snapshots from AlphaESS via getOneDayPowerBySn.
+//  4. Writes a synthetic ReadingItem per snapshot using the same field
+//     mapping the past-date Day Detail fallback already uses (cbat → soc,
+//     gridCharge−feedIn → pgrid, load−ppv−pgrid → pbat).
+//
+// Writes are idempotent — re-running is safe. Backfilled rows are
+// 5-minute-granular (not the live 10 s cadence) but that is good enough for
+// the Day Detail chart and recovers `low24h`.
+//
+// Usage:
+//
+//	ALPHA_APP_ID=... ALPHA_APP_SECRET=... SYSTEM_SERIAL=... \
+//	go run ./cmd/backfill-readings \
+//	    --from=2026-05-18 --to=2026-05-18 \
+//	    --table-readings=flux-readings \
+//	    [--dry-run]
+//
+// Defaults: from = today - 2, to = today (Sydney TZ).
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"log/slog"
+	"os"
+	"strconv"
+	"time"
+
+	_ "time/tzdata"
+
+	awsconfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"
+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
+
+	"github.com/ArjenSchwarz/flux/internal/alphaess"
+	"github.com/ArjenSchwarz/flux/internal/dynamo"
+)
+
+const (
+	// batchWriteMax is the DynamoDB BatchWriteItem limit per request.
+	batchWriteMax = 25
+	// perCallDelay paces AlphaESS calls so a multi-day backfill doesn't
+	// hammer the upstream. Matches cmd/backfill-daily-power.
+	perCallDelay = 500 * time.Millisecond
+)
+
+type opts struct {
+	serial        string
+	tableReadings string
+	from          string
+	to            string
+	dryRun        bool
+}
+
+func main() {
+	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
+
+	loc, err := time.LoadLocation("Australia/Sydney")
+	if err != nil {
+		slog.Error("load Sydney timezone", "error", err)
+		os.Exit(1)
+	}
+	today := time.Now().In(loc)
+	defaultTo := today.Format("2006-01-02")
+	defaultFrom := today.AddDate(0, 0, -2).Format("2006-01-02")
+
+	o := opts{}
+	flag.StringVar(&o.serial, "serial", os.Getenv("SYSTEM_SERIAL"), "AlphaESS system serial number (or env SYSTEM_SERIAL)")
+	flag.StringVar(&o.tableReadings, "table-readings", envOr("TABLE_READINGS", "flux-readings"), "flux-readings table name (or env TABLE_READINGS)")
+	flag.StringVar(&o.from, "from", defaultFrom, "start date inclusive (YYYY-MM-DD, Sydney TZ)")
+	flag.StringVar(&o.to, "to", defaultTo, "end date inclusive (YYYY-MM-DD, Sydney TZ)")
+	flag.BoolVar(&o.dryRun, "dry-run", false, "fetch from AlphaESS and report planned deletes/writes but make no DynamoDB changes")
+	flag.Parse()
+
+	appID := os.Getenv("ALPHA_APP_ID")
+	appSecret := os.Getenv("ALPHA_APP_SECRET")
+	if appID == "" || appSecret == "" {
+		slog.Error("ALPHA_APP_ID and ALPHA_APP_SECRET env vars are required")
+		os.Exit(2)
+	}
+	if o.serial == "" {
+		slog.Error("--serial (or env SYSTEM_SERIAL) is required")
+		os.Exit(2)
+	}
+
+	from, err := time.ParseInLocation("2006-01-02", o.from, loc)
+	if err != nil {
+		slog.Error("invalid --from", "value", o.from, "error", err)
+		os.Exit(2)
+	}
+	to, err := time.ParseInLocation("2006-01-02", o.to, loc)
+	if err != nil {
+		slog.Error("invalid --to", "value", o.to, "error", err)
+		os.Exit(2)
+	}
+	if to.Before(from) {
+		slog.Error("--to is before --from", "from", o.from, "to", o.to)
+		os.Exit(2)
+	}
+
+	ctx := context.Background()
+	client := alphaess.NewClient(appID, appSecret, 10*time.Second)
+
+	awsCfg, err := awsconfig.LoadDefaultConfig(ctx)
+	if err != nil {
+		slog.Error("load AWS config", "error", err)
+		os.Exit(1)
+	}
+	ddb := dynamodb.NewFromConfig(awsCfg)
+	reader := dynamo.NewDynamoReader(ddb, dynamo.TableNames{Readings: o.tableReadings})
+
+	now := time.Now().UTC()
+
+	var totals struct {
+		days, daysSkipped, deleted, written int
+	}
+	for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
+		if d != from {
+			time.Sleep(perCallDelay)
+		}
+		date := d.Format("2006-01-02")
+		dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
+		dayEnd := dayStart.AddDate(0, 0, 1)
+
+		// 1. Query existing readings and identify the all-zero ones.
+		existing, err := reader.QueryReadings(ctx, o.serial, dayStart.Unix(), dayEnd.Unix()-1)
+		if err != nil {
+			slog.Error("query existing readings failed", "date", date, "error", err)
+			totals.daysSkipped++
+			continue
+		}
+		var zeros []dynamo.ReadingItem
+		for _, r := range existing {
+			if dynamo.IsAllZeroReading(r) {
+				zeros = append(zeros, r)
+			}
+		}
+
+		// 2. Fetch the 5-minute snapshots.
+		snapshots, err := client.GetOneDayPower(ctx, o.serial, date)
+		if err != nil {
+			slog.Error("AlphaESS fetch failed", "date", date, "error", err)
+			totals.daysSkipped++
+			continue
+		}
+		if len(snapshots) == 0 {
+			slog.Warn("no snapshots returned by AlphaESS; nothing to backfill",
+				"date", date, "zerosFound", len(zeros))
+			totals.daysSkipped++
+			continue
+		}
+
+		// 3. Build synthetic ReadingItems.
+		items := make([]dynamo.ReadingItem, 0, len(snapshots))
+		var skipped int
+		for _, snap := range snapshots {
+			item, err := dynamo.NewReadingItemFromSnapshot(o.serial, snap, loc, now)
+			if err != nil {
+				slog.Warn("skipping unparseable snapshot", "date", date, "uploadTime", snap.UploadTime, "error", err)
+				skipped++
+				continue
+			}
+			items = append(items, item)
+		}
+
+		// 4. Apply deletes + writes (or report under dry-run).
+		if o.dryRun {
+			slog.Info("dry-run plan",
+				"date", date,
+				"existing", len(existing),
+				"zerosToDelete", len(zeros),
+				"snapshotsFetched", len(snapshots),
+				"syntheticReadingsToWrite", len(items),
+				"snapshotsSkipped", skipped,
+			)
+			totals.days++
+			continue
+		}
+
+		if err := batchDeleteReadings(ctx, ddb, o.tableReadings, zeros); err != nil {
+			slog.Error("delete zero readings failed", "date", date, "error", err)
+			totals.daysSkipped++
+			continue
+		}
+		if err := batchWriteReadings(ctx, ddb, o.tableReadings, items); err != nil {
+			slog.Error("write synthetic readings failed", "date", date, "error", err)
+			totals.daysSkipped++
+			continue
+		}
+		slog.Info("backfilled",
+			"date", date,
+			"existing", len(existing),
+			"zerosDeleted", len(zeros),
+			"snapshotsFetched", len(snapshots),
+			"syntheticReadingsWritten", len(items),
+			"snapshotsSkipped", skipped,
+		)
+		totals.days++
+		totals.deleted += len(zeros)
+		totals.written += len(items)
+	}
+
+	slog.Info("backfill complete",
+		"daysProcessed", totals.days,
+		"daysSkipped", totals.daysSkipped,
+		"totalZeroRowsDeleted", totals.deleted,
+		"totalSyntheticReadingsWritten", totals.written,
+		"dryRun", o.dryRun,
+	)
+	if totals.daysSkipped > 0 {
+		os.Exit(1)
+	}
+}
+
+// batchDeleteReadings deletes the given ReadingItems from the readings table
+// using BatchWriteItem (25 per request). No-op when items is empty.
+func batchDeleteReadings(ctx context.Context, ddb *dynamodb.Client, table string, items []dynamo.ReadingItem) error {
+	if len(items) == 0 {
+		return nil
+	}
+	for i := 0; i < len(items); i += batchWriteMax {
+		end := i + batchWriteMax
+		if end > len(items) {
+			end = len(items)
+		}
+		requests := make([]types.WriteRequest, 0, end-i)
+		for _, r := range items[i:end] {
+			requests = append(requests, types.WriteRequest{
+				DeleteRequest: &types.DeleteRequest{Key: map[string]types.AttributeValue{
+					"sysSn":     &types.AttributeValueMemberS{Value: r.SysSn},
+					"timestamp": &types.AttributeValueMemberN{Value: strconv.FormatInt(r.Timestamp, 10)},
+				}},
+			})
+		}
+		if err := submitBatch(ctx, ddb, table, requests, "delete"); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// batchWriteReadings writes the given ReadingItems via BatchWriteItem.
+func batchWriteReadings(ctx context.Context, ddb *dynamodb.Client, table string, items []dynamo.ReadingItem) error {
+	if len(items) == 0 {
+		return nil
+	}
+	for i := 0; i < len(items); i += batchWriteMax {
+		end := i + batchWriteMax
+		if end > len(items) {
+			end = len(items)
+		}
+		requests := make([]types.WriteRequest, 0, end-i)
+		for _, r := range items[i:end] {
+			av, err := attributevalue.MarshalMap(r)
+			if err != nil {
+				return fmt.Errorf("marshal reading (sysSn=%s, ts=%d): %w", r.SysSn, r.Timestamp, err)
+			}
+			requests = append(requests, types.WriteRequest{
+				PutRequest: &types.PutRequest{Item: av},
+			})
+		}
+		if err := submitBatch(ctx, ddb, table, requests, "write"); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// submitBatch sends a single BatchWriteItem request and retries any
+// unprocessed items once. Mirrors the existing WriteDailyPower retry pattern.
+func submitBatch(ctx context.Context, ddb *dynamodb.Client, table string, requests []types.WriteRequest, action string) error {
+	out, err := ddb.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{
+		RequestItems: map[string][]types.WriteRequest{table: requests},
+	})
+	if err != nil {
+		return fmt.Errorf("batch %s (table=%s): %w", action, table, err)
+	}
+	if len(out.UnprocessedItems) > 0 {
+		slog.Warn("retrying unprocessed items", "table", table, "action", action,
+			"count", len(out.UnprocessedItems[table]))
+		out, err = ddb.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{
+			RequestItems: out.UnprocessedItems,
+		})
+		if err != nil {
+			return fmt.Errorf("retry batch %s (table=%s): %w", action, table, err)
+		}
+		if len(out.UnprocessedItems) > 0 {
+			return fmt.Errorf("batch %s (table=%s): %d items still unprocessed after retry",
+				action, table, len(out.UnprocessedItems[table]))
+		}
+	}
+	return nil
+}
+
+func envOr(key, fallback string) string {
+	if v := os.Getenv(key); v != "" {
+		return v
+	}
+	return fallback
+}
docs/flux-v1.md Modified doc correction
diff --git a/docs/flux-v1.md b/docs/flux-v1.md
index 9afb705..b090bd5 100644
--- a/docs/flux-v1.md
+++ b/docs/flux-v1.md
@@ -93,7 +93,7 @@ The server rejects requests where the timestamp drifts more than 300 seconds fro
 |Endpoint              |Interval                  |Params              |Data Retrieved                                                                                                                                                                                                                                                                         |
 |----------------------|--------------------------|--------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
 |`getLastPowerData`    |10 seconds                |`sysSn`             |Real-time: solar (ppv), battery (pbat), grid (pgrid), load (pload), state of charge (soc), per-phase detail                                                                                                                                                                            |
-|`getOneDayPowerBySn`  |~1 hour                   |`sysSn`, `queryDate`|5-minute battery state snapshots (cbat) for the current day. Power fields return 0 — only cbat is usable. Used for 24h battery low and off-peak battery delta calculations                                                                                                             |
+|`getOneDayPowerBySn`  |~1 hour                   |`sysSn`, `queryDate`|5-minute snapshots: battery state (cbat), solar (ppv), house load (load), grid export (feedIn), grid charge into battery (gridCharge). Used for the past-date Day Detail chart fallback (via `mapDailyPowerToPoints`), 24h battery low, off-peak battery delta calculation, and the T-1274 readings backfill (via `NewReadingItemFromSnapshot`). pgrid/pbat are reconstructed from these via `alphaess.DerivePower`                                                              |
 |`getOneDateEnergyBySn`|~6 hours + off-peak window|`sysSn`, `queryDate`|Daily energy totals: solar (epv), grid import (eInput), grid export (eOutput), battery charge (eCharge), battery discharge (eDischarge), grid charge (eGridCharge). Also called at the start and end of the off-peak window to compute off-peak deltas (see Off-Peak Calculation below)|
 |`getEssList`          |~24 hours                 |—                   |System info: serial number (sysSn), battery capacity (cobat: 13.34 kWh), model, inverter capacity                                                                                                                                                                                      |

docs/agent-notes/api-layer.md Modified +1 / -0
diff --git a/docs/agent-notes/api-layer.md b/docs/agent-notes/api-layer.md
index 7e354fa..8023140 100644
--- a/docs/agent-notes/api-layer.md
+++ b/docs/agent-notes/api-layer.md
@@ -37,6 +37,7 @@
 - Captures `now` once via `h.nowFunc()` for time consistency within a request.
 - Phase 1: errgroup with 4 concurrent DynamoDB queries (readings 24h, system, offpeak, daily energy). Any failure → 500.
 - Phase 2: in-memory computation — extract latest reading, filter to 60s/15min subsets, compute pgridSustained, rolling averages, cutoff estimates, MinSOC for `low24h` (filtered to readings since 00:00 Sydney local on `now`'s date via `startOfDaySydney`; field name preserved for wire compatibility — see `specs/low-since-offpeak/decision_log.md` Decision 4). No off-peak dependency on this path.
+- `liveFresh` gate (T-1274): `resp.Live`, `battery.EstimatedCutoff`, and `rolling15min.EstimatedCutoff` are populated only when the most recent reading is within `liveDataStalenessThreshold` (90 s). Past that, the dashboard's existing "Awaiting live data" UI surfaces instead of presenting an aged reading as current. `low24h` and the rolling averages themselves still derive from their own time-windowed subsets, which already produce correct results when the latest reading is stale.
 - `filterReadings(readings, from, to)` — returns subset by timestamp range.
 - `buildOffpeak(item, today, windowStart, windowEnd)` — always includes window times. Deltas come from a complete record's final values, or are projected from today's running `DailyEnergyItem` against the pending record's start snapshot. Pending without a daily-energy item leaves deltas null. The response carries a `status` field (`"pending"` or `"complete"`) so clients can mark in-progress data; `batteryDeltaPercent` is unavailable mid-window because no current SOC is computed in this slice.
 - `floatPtr(v)` — helper for nullable float64 fields.
docs/agent-notes/dynamo-layer.md Modified +2 / -0
diff --git a/docs/agent-notes/dynamo-layer.md b/docs/agent-notes/dynamo-layer.md
index 18fe26d..5dba0e6 100644
--- a/docs/agent-notes/dynamo-layer.md
+++ b/docs/agent-notes/dynamo-layer.md
@@ -31,6 +31,8 @@
 - `DynamoNoteWriter.DeleteNote` is unconditionally idempotent: DynamoDB `DeleteItem` succeeds for missing keys without a `ConditionExpression`, so no pre-check is needed.
 - `DynamoNoteWriter.PutNote` carries no `ConditionExpression` — concurrent saves resolve last-write-wins, intentional per design Decision 5.
 - `QueryNotes` uses `#d` expression attribute name (date is a reserved word) and a `BETWEEN` sort-key condition over `[startDate, endDate]`.
+- `NewReadingItemFromSnapshot` (T-1274) converts a 5-minute `alphaess.PowerSnapshot` into a `ReadingItem` using `alphaess.DerivePower` for pgrid/pbat. Used by `cmd/backfill-readings` to replace the all-zero rows the AlphaESS overnight outage produced. TTL uses `now`, not the snapshot's age, so backfilled rows still get a full 30-day lease.
+- `IsAllZeroReading` (T-1274) — every-field-zero detector used by the backfill tool to identify safe-to-delete rows. Companion to `internal/poller.isAllZeroPower` and `internal/poller.isAllZeroEnergy`; the three live in three packages because they operate on three different types but share the same semantic ("AlphaESS no-data sentinel").

 ## Testing Patterns

docs/agent-notes/poller-orchestrator.md Modified +2 / -0
diff --git a/docs/agent-notes/poller-orchestrator.md b/docs/agent-notes/poller-orchestrator.md
index 00d991b..25a9dcb 100644
--- a/docs/agent-notes/poller-orchestrator.md
+++ b/docs/agent-notes/poller-orchestrator.md
@@ -19,6 +19,8 @@
 - `wallClockTime` constructs a specific wall-clock time using `time.Date` for DST safety.
 - Mid-window recovery: queries store for pending record, recovers start snapshot from it. Store errors are logged and skipped (not propagated).
 - Failed end snapshot: deletes pending record via `store.DeleteOffpeak`.
+- `fetchAndStoreLiveData` refuses to persist all-zero `PowerData` (T-1274). AlphaESS occasionally returns `code:200` with `data:null` for `getLastPowerData` overnight; without this guard the silently-unmarshalled zero struct gets written every 10 s and the iOS Dashboard renders 0% / 0 W as if live. The skip path logs the raw `ppv/pload/pbat/pgrid/soc` values at warn so the upstream behaviour is visible in CloudWatch. `isAllZeroPower` is the sibling of the existing `isAllZeroEnergy` (same shape, different field set).
+- `internal/alphaess/client.go::GetLastPowerData` is the layer that turns `data:null` / empty `Data` into an error, so the poller's existing error-skip path catches the structurally-empty variant before it reaches `isAllZeroPower`.

 ## Testing Patterns

internal/alphaess/client.go Modified Layer 1
diff --git a/internal/alphaess/client.go b/internal/alphaess/client.go
index c631010..74094f1 100644
--- a/internal/alphaess/client.go
+++ b/internal/alphaess/client.go
@@ -1,6 +1,7 @@
 package alphaess

 import (
+	"bytes"
 	"context"
 	"crypto/sha512"
 	"encoding/hex"
@@ -90,11 +91,21 @@ func (c *Client) doGet(ctx context.Context, endpoint string, params map[string]s
 }

 // GetLastPowerData retrieves real-time power data for the given serial number.
+//
+// AlphaESS occasionally returns `code: 200` with `data: null` (or an empty
+// data field) when the inverter isn't actively publishing live values —
+// observed overnight when the system is quiet. Unmarshalling `null` into
+// PowerData silently produces every-field-zero, which would otherwise be
+// written into flux-readings as a phoney "everything is zero" reading. Turn
+// no-data into an error so the poller logs and skips instead.
 func (c *Client) GetLastPowerData(ctx context.Context, serial string) (*PowerData, error) {
 	data, err := c.doGet(ctx, "getLastPowerData", map[string]string{"sysSn": serial})
 	if err != nil {
 		return nil, err
 	}
+	if isNullJSON(data) {
+		return nil, fmt.Errorf("getLastPowerData: no data in response (sysSn=%s)", serial)
+	}

 	var result PowerData
 	if err := json.Unmarshal(data, &result); err != nil {
@@ -103,6 +114,12 @@ func (c *Client) GetLastPowerData(ctx context.Context, serial string) (*PowerDat
 	return &result, nil
 }

+// isNullJSON reports whether a raw JSON value is missing or the literal
+// `null` — both meaning "no data" from AlphaESS.
+func isNullJSON(data json.RawMessage) bool {
+	return len(data) == 0 || bytes.Equal(bytes.TrimSpace(data), []byte("null"))
+}
+
 // GetOneDayPower retrieves 5-minute power snapshots for the given serial and date.
 func (c *Client) GetOneDayPower(ctx context.Context, serial, date string) ([]PowerSnapshot, error) {
 	data, err := c.doGet(ctx, "getOneDayPowerBySn", map[string]string{"sysSn": serial, "queryDate": date})
internal/alphaess/client_test.go Modified tests
diff --git a/internal/alphaess/client_test.go b/internal/alphaess/client_test.go
index b61d16f..0f96915 100644
--- a/internal/alphaess/client_test.go
+++ b/internal/alphaess/client_test.go
@@ -87,6 +87,41 @@ func TestGetLastPowerData_Success(t *testing.T) {
 	assert.Equal(t, 85.0, got.Soc)
 }

+// T-1274 regression: AlphaESS sometimes returns `code: 200` with `data: null`
+// overnight when the inverter isn't publishing live data. json.Unmarshal of a
+// JSON null silently produces a zero-valued PowerData, which the poller would
+// then write as a phoney "everything is zero" reading. The client must turn
+// no-data into an error so the poller skips the write.
+func TestGetLastPowerData_NullData_ReturnsError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		writeJSON(t, w, apiResponse{Code: 200, Msg: "Success", Data: json.RawMessage("null")})
+	}))
+	defer srv.Close()
+
+	c := NewClient("app", "secret", 10*time.Second)
+	c.baseURL = srv.URL
+
+	_, err := c.GetLastPowerData(context.Background(), "SN123")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "no data")
+}
+
+// Companion: an empty Data field (some flavours of "no data" return an empty
+// string rather than the JSON literal `null`) is treated the same way.
+func TestGetLastPowerData_EmptyData_ReturnsError(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		writeJSON(t, w, apiResponse{Code: 200, Msg: "Success", Data: nil})
+	}))
+	defer srv.Close()
+
+	c := NewClient("app", "secret", 10*time.Second)
+	c.baseURL = srv.URL
+
+	_, err := c.GetLastPowerData(context.Background(), "SN123")
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "no data")
+}
+
 func TestGetOneDateEnergy_Success(t *testing.T) {
 	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		data := EnergyData{Epv: 12.5, EInput: 3.0, EOutput: 1.5, ECharge: 5.0, EDischarge: 2.0, EGridCharge: 0.5}
internal/alphaess/models.go Modified DerivePower
diff --git a/internal/alphaess/models.go b/internal/alphaess/models.go
index 0ec37a4..3c69871 100644
--- a/internal/alphaess/models.go
+++ b/internal/alphaess/models.go
@@ -39,6 +39,19 @@ type PowerSnapshot struct {
 	UploadTime string  `json:"uploadTime"`
 }

+// DerivePower reconstructs the live-reading-shape (pgrid, pbat) from a 5-minute
+// snapshot's gross fields. Sign conventions match the live readings produced
+// by getLastPowerData: pgrid > 0 = importing, pbat > 0 = discharging.
+//
+// Both consumers — Day Detail's past-date fallback (mapDailyPowerToPoints) and
+// the readings backfill (NewReadingItemFromSnapshot) — go through this so the
+// invariant can only drift in one place.
+func DerivePower(load, ppv, gridCharge, feedIn float64) (pgrid, pbat float64) {
+	pgrid = gridCharge - feedIn
+	pbat = load - ppv - pgrid
+	return
+}
+
 // SystemInfo represents a single system entry from getEssList.
 type SystemInfo struct {
 	SysSn     string  `json:"sysSn"`
internal/alphaess/models_test.go New tests
diff --git a/internal/alphaess/models_test.go b/internal/alphaess/models_test.go
new file mode 100644
index 0000000..708bbaa
--- /dev/null
+++ b/internal/alphaess/models_test.go
@@ -0,0 +1,48 @@
+package alphaess
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// DerivePower must agree with the live-reading sign convention used by
+// computeTodayEnergy and the iOS BatteryPowerChartView: pgrid > 0 means
+// importing from the grid, pbat > 0 means the battery is discharging. The
+// helper exists so the past-date Day Detail fallback (mapDailyPowerToPoints)
+// and the readings backfill (NewReadingItemFromSnapshot) cannot disagree.
+func TestDerivePower(t *testing.T) {
+	tests := map[string]struct {
+		load, ppv, gridCharge, feedIn float64
+		wantPgrid, wantPbat           float64
+	}{
+		"importing — gridCharge > feedIn, ppv < load": {
+			load: 400, ppv: 100, gridCharge: 50, feedIn: 0,
+			wantPgrid: 50,  // 50 - 0
+			wantPbat:  250, // 400 - 100 - 50 (discharging to cover the remaining load)
+		},
+		"exporting — feedIn > gridCharge, ppv > load": {
+			load: 500, ppv: 3000, gridCharge: 0, feedIn: 2000,
+			wantPgrid: -2000, // 0 - 2000 (export)
+			wantPbat:  -500,  // 500 - 3000 - (-2000) (charging from excess solar)
+		},
+		"idle — every field zero": {
+			load: 0, ppv: 0, gridCharge: 0, feedIn: 0,
+			wantPgrid: 0,
+			wantPbat:  0,
+		},
+		"solar covers load exactly": {
+			load: 1000, ppv: 1000, gridCharge: 0, feedIn: 0,
+			wantPgrid: 0,
+			wantPbat:  0,
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			pgrid, pbat := DerivePower(tc.load, tc.ppv, tc.gridCharge, tc.feedIn)
+			assert.Equal(t, tc.wantPgrid, pgrid)
+			assert.Equal(t, tc.wantPbat, pbat)
+		})
+	}
+}
internal/api/day.go Modified uses DerivePower
diff --git a/internal/api/day.go b/internal/api/day.go
index 31e8e32..df6bc1d 100644
--- a/internal/api/day.go
+++ b/internal/api/day.go
@@ -6,6 +6,7 @@ import (
 	"regexp"
 	"time"

+	"github.com/ArjenSchwarz/flux/internal/alphaess"
 	"github.com/ArjenSchwarz/flux/internal/derivedstats"
 	"github.com/ArjenSchwarz/flux/internal/dynamo"
 	"github.com/aws/aws-lambda-go/events"
@@ -209,11 +210,9 @@ func (h *Handler) handleDay(ctx context.Context, req events.LambdaFunctionURLReq

 // mapDailyPowerToPoints converts fallback daily power items to time series points.
 // Maps the 5-minute snapshot fields onto the live-reading shape: cbat → soc,
-// load → pload, ppv → ppv, gridCharge − feedIn → pgrid (positive = import),
-// and pbat is derived from the instantaneous power balance
-// pbat = pload − ppv − pgrid (positive = discharging; matches the live reading
-// convention used by computeTodayEnergy and BatteryPowerChartView). Used
-// directly without downsampling.
+// load → pload, ppv → ppv, and (pgrid, pbat) via alphaess.DerivePower (matches
+// the live-reading sign convention used by computeTodayEnergy and
+// BatteryPowerChartView). Used directly without downsampling.
 func mapDailyPowerToPoints(items []dynamo.DailyPowerItem) []TimeSeriesPoint {
 	points := make([]TimeSeriesPoint, 0, len(items))
 	for _, item := range items {
@@ -222,8 +221,7 @@ func mapDailyPowerToPoints(items []dynamo.DailyPowerItem) []TimeSeriesPoint {
 			slog.Warn("skipping daily power item with unparseable uploadTime", "uploadTime", item.UploadTime, "error", err)
 			continue
 		}
-		pgrid := item.GridCharge - item.FeedIn
-		pbat := item.Load - item.Ppv - pgrid
+		pgrid, pbat := alphaess.DerivePower(item.Load, item.Ppv, item.GridCharge, item.FeedIn)
 		points = append(points, TimeSeriesPoint{
 			Timestamp: t.UTC().Format(time.RFC3339),
 			Soc:       roundPower(item.Cbat),
internal/api/status.go Modified Layer 3
diff --git a/internal/api/status.go b/internal/api/status.go
index ccff26e..a25acf6 100644
--- a/internal/api/status.go
+++ b/internal/api/status.go
@@ -17,6 +17,11 @@ const (
 	// cutoffPercent is the fixed battery cutoff threshold.
 	// Mirrored in iOS FluxCore/BatteryEnergy.swift — update both on hardware changes.
 	cutoffPercent = 5
+	// liveDataStalenessThreshold bounds how old the most recent reading can
+	// be before /status stops surfacing it as live. The poller writes every
+	// 10 s, so nine consecutive missed writes (90 s) is unambiguously broken
+	// — most commonly AlphaESS going quiet overnight (T-1274).
+	liveDataStalenessThreshold = 90 * time.Second
 )

 func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse {
@@ -75,7 +80,12 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe
 	resp := &StatusResponse{}

 	// Live data from latest reading (last element of ascending-sorted results).
-	if len(allReadings) > 0 {
+	// Treat the reading as live only when it is recent: a stale `Live` payload
+	// would be rendered as current on the dashboard, hiding overnight gaps
+	// when AlphaESS stops returning fresh snapshots (T-1274).
+	liveFresh := len(allReadings) > 0 &&
+		nowUnix-allReadings[len(allReadings)-1].Timestamp <= int64(liveDataStalenessThreshold.Seconds())
+	if liveFresh {
 		latest := allReadings[len(allReadings)-1]
 		sixtySecReadings := filterReadings(allReadings, nowUnix-60, nowUnix)

@@ -107,7 +117,7 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe
 	// projected cutoff past the boundary never actually occurs.
 	nextOpWindowStart, hasOffpeakBoundary := nextOffpeakStart(now, h.offpeakStart, h.offpeakEnd)

-	if len(allReadings) > 0 {
+	if liveFresh {
 		latest := allReadings[len(allReadings)-1]
 		if ct := computeCutoffTime(latest.Soc, latest.Pbat, capacity, cutoffPercent, now); ct != nil {
 			if !hasOffpeakBoundary || ct.Before(nextOpWindowStart) {
@@ -140,7 +150,7 @@ func (h *Handler) handleStatus(ctx context.Context, _ events.LambdaFunctionURLRe
 			AvgLoad: roundPower(avgLoad),
 			AvgPbat: roundPower(avgPbat),
 		}
-		if len(allReadings) > 0 {
+		if liveFresh {
 			latest := allReadings[len(allReadings)-1]
 			if ct := computeCutoffTime(latest.Soc, avgPbat, capacity, cutoffPercent, now); ct != nil {
 				if !hasOffpeakBoundary || ct.Before(nextOpWindowStart) {
internal/api/status_test.go Modified tests
diff --git a/internal/api/status_test.go b/internal/api/status_test.go
index b78cdff..ee96e3e 100644
--- a/internal/api/status_test.go
+++ b/internal/api/status_test.go
@@ -120,6 +120,78 @@ func TestHandleStatusAllDataPresent(t *testing.T) {
 	assert.Equal(t, roundEnergy(12.345), sr.TodayEnergy.Epv)
 }

+// T-1274 regression: when AlphaESS stops returning fresh data overnight, the
+// poller stops writing new readings and the most recent reading ages. /status
+// must not surface that aged reading as if it were live — the iOS Dashboard
+// has no other way to know the data is stale and would render an hours-old
+// snapshot as current. Threshold tracks `liveDataStalenessThreshold`.
+func TestHandleStatusStaleLatestReading_OmitsLive(t *testing.T) {
+	now := fixedNow()
+	nowUnix := now.Unix()
+	// Latest reading is 2 hours old — well past any sane "live" threshold.
+	staleUnix := nowUnix - 2*3600
+	mr := &mockReader{
+		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {
+			return []dynamo.ReadingItem{
+				{Timestamp: staleUnix, Ppv: 0, Pload: 200, Pbat: 250, Pgrid: 150, Soc: 65},
+			}, nil
+		},
+	}
+
+	h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")
+	h.nowFunc = func() time.Time { return now }
+
+	resp, err := h.Handle(context.Background(), statusRequest())
+	require.NoError(t, err)
+	assert.Equal(t, 200, resp.StatusCode)
+
+	sr := parseStatusResponse(t, resp)
+	assert.Nil(t, sr.Live, "live should be omitted when latest reading is stale")
+	require.NotNil(t, sr.Battery)
+	assert.Nil(t, sr.Battery.EstimatedCutoff, "cutoff should be omitted when based on a stale reading")
+}
+
+// Boundary: a reading exactly at the staleness threshold is still treated as
+// fresh; one second past it is not.
+func TestHandleStatusStalenessBoundary(t *testing.T) {
+	now := fixedNow()
+	nowUnix := now.Unix()
+
+	cases := map[string]struct {
+		ageSec    int64
+		wantLive  bool
+	}{
+		"fresh":             {ageSec: 10, wantLive: true},
+		"at threshold":      {ageSec: 90, wantLive: true},
+		"one past threshold": {ageSec: 91, wantLive: false},
+	}
+
+	for name, tc := range cases {
+		t.Run(name, func(t *testing.T) {
+			mr := &mockReader{
+				queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {
+					return []dynamo.ReadingItem{
+						{Timestamp: nowUnix - tc.ageSec, Ppv: 0, Pload: 200, Pbat: 50, Pgrid: 150, Soc: 75},
+					}, nil
+				},
+			}
+
+			h := NewHandler(mr, nil, testSerial, testToken, "11:00", "14:00")
+			h.nowFunc = func() time.Time { return now }
+
+			resp, err := h.Handle(context.Background(), statusRequest())
+			require.NoError(t, err)
+			sr := parseStatusResponse(t, resp)
+
+			if tc.wantLive {
+				assert.NotNil(t, sr.Live, "expected Live populated at age=%ds", tc.ageSec)
+			} else {
+				assert.Nil(t, sr.Live, "expected Live omitted at age=%ds", tc.ageSec)
+			}
+		})
+	}
+}
+
 func TestHandleStatusNoReadings(t *testing.T) {
 	mr := &mockReader{
 		queryReadingsFn: func(_ context.Context, _ string, _, _ int64) ([]dynamo.ReadingItem, error) {
internal/dynamo/models.go Modified Layer 4 support
diff --git a/internal/dynamo/models.go b/internal/dynamo/models.go
index 602e5f1..adc8516 100644
--- a/internal/dynamo/models.go
+++ b/internal/dynamo/models.go
@@ -1,6 +1,7 @@
 package dynamo

 import (
+	"fmt"
 	"time"

 	"github.com/ArjenSchwarz/flux/internal/alphaess"
@@ -163,6 +164,47 @@ func NewReadingItem(serial string, data *alphaess.PowerData, now time.Time) Read
 	}
 }

+// NewReadingItemFromSnapshot derives a ReadingItem from a 5-minute AlphaESS
+// PowerSnapshot (`getOneDayPowerBySn`). Used by the backfill tool to fill in
+// synthetic readings when the live poll path lost data — e.g. the T-1274
+// overnight outage where `getLastPowerData` returned null and the poller
+// either skipped writes or stored zero rows.
+//
+// Power and grid fields are reconstructed using the same mapping
+// `mapDailyPowerToPoints` uses for the past-date Day Detail fallback:
+//   - soc = cbat
+//   - pgrid = gridCharge - feedIn (positive = importing)
+//   - pbat  = load - ppv - pgrid (positive = discharging)
+//
+// `now` is used for the 30-day TTL so backfilled rows get a fresh lease on
+// life instead of inheriting the snapshot's age.
+func NewReadingItemFromSnapshot(serial string, snap alphaess.PowerSnapshot, loc *time.Location, now time.Time) (ReadingItem, error) {
+	t, err := time.ParseInLocation("2006-01-02 15:04:05", snap.UploadTime, loc)
+	if err != nil {
+		return ReadingItem{}, fmt.Errorf("parse uploadTime %q: %w", snap.UploadTime, err)
+	}
+	pgrid, pbat := alphaess.DerivePower(snap.Load, snap.Ppv, snap.GridCharge, snap.FeedIn)
+	return ReadingItem{
+		SysSn:     serial,
+		Timestamp: t.Unix(),
+		Ppv:       snap.Ppv,
+		Pload:     snap.Load,
+		Pbat:      pbat,
+		Pgrid:     pgrid,
+		Soc:       snap.Cbat,
+		TTL:       now.Add(ttl30Days).Unix(),
+	}, nil
+}
+
+// IsAllZeroReading reports whether every power/SoC field on a stored
+// ReadingItem is exactly zero — the bogus pattern T-1274 introduced when
+// AlphaESS returned null `getLastPowerData` payloads overnight. Used by the
+// backfill tool to identify rows safe to delete; a working battery system
+// never legitimately produces every-field-zero.
+func IsAllZeroReading(r ReadingItem) bool {
+	return r.Ppv == 0 && r.Pload == 0 && r.Pbat == 0 && r.Pgrid == 0 && r.Soc == 0
+}
+
 // NewDailyEnergyItem transforms AlphaESS energy data into a DynamoDB daily energy item.
 func NewDailyEnergyItem(serial, date string, data *alphaess.EnergyData) DailyEnergyItem {
 	return DailyEnergyItem{
internal/dynamo/models_test.go Modified tests
diff --git a/internal/dynamo/models_test.go b/internal/dynamo/models_test.go
index 597d228..da618b4 100644
--- a/internal/dynamo/models_test.go
+++ b/internal/dynamo/models_test.go
@@ -52,6 +52,93 @@ func TestNewReadingItem(t *testing.T) {
 	}
 }

+func TestNewReadingItemFromSnapshot(t *testing.T) {
+	loc, err := time.LoadLocation("Australia/Sydney")
+	if err != nil {
+		t.Fatalf("load Sydney: %v", err)
+	}
+	now := time.Date(2026, 5, 19, 9, 0, 0, 0, time.UTC)
+
+	tests := map[string]struct {
+		serial  string
+		snap    alphaess.PowerSnapshot
+		wantTS  int64
+		wantPb  float64
+		wantPg  float64
+		wantSoc float64
+		wantErr bool
+	}{
+		"importing — gridCharge > feedIn, ppv < load": {
+			serial: "SN1",
+			snap: alphaess.PowerSnapshot{
+				Cbat: 72.0, Ppv: 100, Load: 400, FeedIn: 0, GridCharge: 50,
+				UploadTime: "2026-05-18 23:30:00",
+			},
+			// pgrid = 50 - 0 = 50 (import); pbat = 400 - 100 - 50 = 250 (discharge)
+			wantTS:  time.Date(2026, 5, 18, 23, 30, 0, 0, loc).Unix(),
+			wantPb:  250,
+			wantPg:  50,
+			wantSoc: 72.0,
+		},
+		"exporting — feedIn > gridCharge": {
+			serial: "SN1",
+			snap: alphaess.PowerSnapshot{
+				Cbat: 95.0, Ppv: 3000, Load: 500, FeedIn: 2000, GridCharge: 0,
+				UploadTime: "2026-05-18 13:00:00",
+			},
+			// pgrid = 0 - 2000 = -2000 (export); pbat = 500 - 3000 - (-2000) = -500 (charge)
+			wantTS:  time.Date(2026, 5, 18, 13, 0, 0, 0, loc).Unix(),
+			wantPb:  -500,
+			wantPg:  -2000,
+			wantSoc: 95.0,
+		},
+		"invalid uploadTime returns error": {
+			serial:  "SN1",
+			snap:    alphaess.PowerSnapshot{UploadTime: "not-a-time"},
+			wantErr: true,
+		},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			got, err := NewReadingItemFromSnapshot(tc.serial, tc.snap, loc, now)
+			if tc.wantErr {
+				assert.Error(t, err)
+				return
+			}
+			assert.NoError(t, err)
+			assert.Equal(t, tc.serial, got.SysSn)
+			assert.Equal(t, tc.wantTS, got.Timestamp)
+			assert.Equal(t, tc.wantPb, got.Pbat)
+			assert.Equal(t, tc.wantPg, got.Pgrid)
+			assert.Equal(t, tc.wantSoc, got.Soc)
+			// TTL must be derived from `now`, not from the snapshot — backfilling
+			// older snapshots shouldn't write rows that expire before they're
+			// useful.
+			assert.Equal(t, now.Add(30*24*time.Hour).Unix(), got.TTL)
+		})
+	}
+}
+
+func TestIsAllZeroReading(t *testing.T) {
+	tests := map[string]struct {
+		r    ReadingItem
+		want bool
+	}{
+		"every field zero":             {r: ReadingItem{SysSn: "SN", Timestamp: 1}, want: true},
+		"non-zero SoC alone":           {r: ReadingItem{Soc: 72.0}, want: false},
+		"non-zero ppv alone":           {r: ReadingItem{Ppv: 0.001}, want: false},
+		"negative pgrid (exporting)":   {r: ReadingItem{Pgrid: -100}, want: false},
+		"sysSn/timestamp do not count": {r: ReadingItem{SysSn: "AB1234", Timestamp: 123456789, TTL: 999}, want: true},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			assert.Equal(t, tc.want, IsAllZeroReading(tc.r))
+		})
+	}
+}
+
 func TestNewDailyEnergyItem(t *testing.T) {
 	tests := map[string]struct {
 		serial string
internal/poller/poller.go Modified Layer 2
diff --git a/internal/poller/poller.go b/internal/poller/poller.go
index 8dfef5a..1ba5c33 100644
--- a/internal/poller/poller.go
+++ b/internal/poller/poller.go
@@ -177,6 +177,18 @@ func (p *Poller) fetchAndStoreLiveData(ctx context.Context) {
 		logDryRunPayload("getLastPowerData", data)
 	}

+	// AlphaESS occasionally returns code:200 with present-but-all-zero values
+	// when the inverter isn't actively reporting (observed overnight). Writing
+	// that as a reading drives the iOS Dashboard to render 0% / 0 W as if
+	// live. Skip the write and log the payload at warn so the gap is visible
+	// in CloudWatch. (T-1274)
+	if isAllZeroPower(data) {
+		slog.Warn("skipping reading write: AlphaESS returned all-zero values (inverter likely not reporting)",
+			"sysSn", p.cfg.Serial,
+			"ppv", data.Ppv, "pload", data.Pload, "pbat", data.Pbat, "pgrid", data.Pgrid, "soc", data.Soc)
+		return
+	}
+
 	item := dynamo.NewReadingItem(p.cfg.Serial, data, p.now())
 	if err := p.store.WriteReading(ctx, item); err != nil {
 		slog.Error("write reading failed", "error", err)
@@ -185,6 +197,14 @@ func (p *Poller) fetchAndStoreLiveData(ctx context.Context) {
 	slog.Info("stored reading", "sysSn", p.cfg.Serial)
 }

+// isAllZeroPower reports whether every field on the live power response is
+// zero. A working battery system never produces an all-zero live snapshot
+// (SoC alone is always positive on a system that has been running), so such
+// a response means AlphaESS isn't actually reporting current values.
+func isAllZeroPower(d *alphaess.PowerData) bool {
+	return d.Ppv == 0 && d.Pload == 0 && d.Pbat == 0 && d.Pgrid == 0 && d.Soc == 0
+}
+
 // fetchAndStoreDailyPower fetches and stores 5-minute power snapshots. If
 // date is empty, uses today in the configured timezone.
 func (p *Poller) fetchAndStoreDailyPower(ctx context.Context, date string) {
internal/poller/poller_test.go Modified tests
diff --git a/internal/poller/poller_test.go b/internal/poller/poller_test.go
index 682ac42..7fc7256 100644
--- a/internal/poller/poller_test.go
+++ b/internal/poller/poller_test.go
@@ -178,6 +178,41 @@ func TestFetchAndStoreLiveData_Success(t *testing.T) {
 	assert.Equal(t, 1, ms.readingsWritten)
 }

+// T-1274 regression: when AlphaESS returns code:200 with a present-but-all-zero
+// payload (observed overnight when the inverter isn't actively reporting), the
+// poller previously wrote a phoney "everything is zero" reading. Those rows
+// then drove the iOS Dashboard to render 0% / 0 W as if live. The poller now
+// recognises the pattern, logs a diagnostic warning that includes the raw
+// values, and skips the write.
+func TestFetchAndStoreLiveData_AllZeroPayload_LogsAndSkips(t *testing.T) {
+	buf, restore := captureLog()
+	defer restore()
+
+	mc := &mockClient{lastPowerData: &alphaess.PowerData{}} // every field zero-valued
+	ms := &mockStore{}
+	p := testPoller(mc, ms)
+
+	p.fetchAndStoreLiveData(context.Background())
+
+	assert.Equal(t, 1, mc.lastPowerCalls)
+	assert.Equal(t, 0, ms.readingsWritten, "all-zero readings must not be persisted")
+	assert.True(t, logContains(buf, "all-zero"), "warning should mention the all-zero condition")
+}
+
+// A reading with valid SoC but legitimately quiet power fields (no solar, no
+// load on grid, battery idle) must still be written — only every-field-zero
+// is the bogus pattern. This pins the threshold so we don't accidentally drop
+// real overnight readings.
+func TestFetchAndStoreLiveData_ValidSocZeroPower_Writes(t *testing.T) {
+	mc := &mockClient{lastPowerData: &alphaess.PowerData{Soc: 72.0}}
+	ms := &mockStore{}
+	p := testPoller(mc, ms)
+
+	p.fetchAndStoreLiveData(context.Background())
+
+	assert.Equal(t, 1, ms.readingsWritten, "a reading with valid SoC must still be persisted")
+}
+
 func TestFetchAndStoreLiveData_APIError_LogsAndSkips(t *testing.T) {
 	buf, restore := captureLog()
 	defer restore()
specs/bugfixes/late-night-current-data-gap/report.md New bugfix report
diff --git a/specs/bugfixes/late-night-current-data-gap/report.md b/specs/bugfixes/late-night-current-data-gap/report.md
new file mode 100644
index 0000000..0ff235c
--- /dev/null
+++ b/specs/bugfixes/late-night-current-data-gap/report.md
@@ -0,0 +1,166 @@
+# Bugfix Report: Dashboard shows all-zero "live" data overnight
+
+**Date:** 2026-05-19
+**Status:** Fixed
+**Transit:** T-1274
+
+## Description of the Issue
+
+From roughly 11 PM Sydney time onwards, the iOS Flux Dashboard becomes "useless" — every live readout (SoC, solar, house load, grid, battery power) shows zero, and stays at zero until well after midnight. The user expected either current values or a clear signal that current data is unavailable; instead the dashboard appears to claim the battery is empty and the system is idle, which is wrong.
+
+**Reproduction steps:**
+
+1. Wait until late evening (around 11 PM Sydney local time) when AlphaESS stops publishing fresh `getLastPowerData` values for this serial.
+2. Open the iOS Dashboard.
+3. Observe SoC = 0%, ppv = 0 W, pload = 0 W, pgrid = 0 W, pbat = 0 W — every live field zero.
+4. The eyebrow still reads "Now · HH:MM" using the device clock, so the dashboard appears to be claiming the system is genuinely off.
+
+**Impact:** User-facing correctness and trust. Showing "0% / 0 W everywhere" is worse than no data — it implies the battery is fully empty and the house is consuming nothing, neither of which is true.
+
+## Investigation Summary
+
+The dashboard's live readout comes from `StatusResponse.Live`, which the `/status` Lambda builds from the most recent row in `flux-readings`. The poller writes that row every 10 s from `client.GetLastPowerData`.
+
+- `internal/alphaess/client.go:92` — `GetLastPowerData` calls `doGet`, then `json.Unmarshal(data, &result)` into a `PowerData` struct. Crucially, `json.Unmarshal([]byte("null"), &result)` succeeds without error and yields a zero-valued struct. So a `code: 200, data: null` response is indistinguishable from a real all-zero reading at the call site.
+- `internal/poller/poller.go:169` — `fetchAndStoreLiveData` only branched on `err`. A zero-valued struct from a `null` payload counted as success, so the poller wrote a `ReadingItem` with every field zero and a freshly stamped `now()` timestamp.
+- `internal/api/status.go:78` — handler took `latest := allReadings[len(allReadings)-1]` and populated `resp.Live` regardless of values or age.
+- `Flux/Flux/Dashboard/DashboardView.swift:113` — renders whatever `live` contains, including zeros.
+
+The user confirmed the symptom is "everything shows 0, no proper data since 11 PM". That rules out the earlier hypothesis that the latest reading was simply aging (which would have left the *previous* non-zero values visible). The poller is actively writing fresh-timestamped zero rows.
+
+**Hypotheses tested:**
+
+- *AlphaESS API errors silently at night.* Ruled out — would log `fetch live data failed`, leave the previous reading in place, and the dashboard would show non-zero stale values. The observed all-zero pattern is incompatible with this path.
+- *AlphaESS returns `code: 200` with no payload (`data: null` or empty).* Confirmed as the most likely explanation. `json.Unmarshal` happily turns either into zero-filled `PowerData`, and the poller writes it.
+- *AlphaESS returns a `code: 200` response carrying an explicit all-zero JSON object.* Possible too — same outcome at the write site. Defended against by the same all-zero check below.
+- *DynamoDB TTL pruning.* Ruled out — TTL is 30 days on `flux-readings`.
+- *iOS app filtering.* Ruled out — the dashboard renders whatever `Live` it receives.
+
+## Discovered Root Cause
+
+Two reinforcing defects:
+
+1. `GetLastPowerData` does not distinguish "no data" from "data with every field zero". `json.Unmarshal` of a JSON `null` (or an empty data field) silently produces a zero-valued struct.
+2. `fetchAndStoreLiveData` writes whatever the client returns, with no sanity check on the values. The result is a fresh `ReadingItem` per 10 s with `ppv=0, pload=0, pbat=0, pgrid=0, soc=0` — which `/status` then dutifully surfaces as the current state.
+
+The combined effect: when AlphaESS goes quiet overnight (which the user reports happens reliably from around 11 PM), the dashboard renders 0% / 0 W / 0 W / 0 W as if those are the real current values.
+
+**Defect type:** Silent acceptance of "no data" responses as if they were valid data.
+
+**Why it occurred:** The error path covered HTTP / transport / decode failures, but a 200 response with a missing payload looked like a success at every layer. The pattern is already addressed for `getOneDateEnergyBySn` (`isAllZeroEnergy` in `internal/poller/poller.go`) — that precedent simply wasn't applied to `getLastPowerData`.
+
+## Resolution for the Issue
+
+Three layered changes:
+
+1. **Client refuses null/empty Data.** `GetLastPowerData` checks for a missing or `null` Data field and returns a descriptive error. The poller's existing `slog.Error("fetch live data failed", ...)` path then logs the no-data response and skips the write — the same way it handles transport errors.
+2. **Poller refuses all-zero values.** `isAllZeroPower` mirrors the existing `isAllZeroEnergy` pattern. When every field is zero, `fetchAndStoreLiveData` logs a warning that includes the raw payload values and returns without writing. This catches the "code: 200, data: {}" variant where the JSON parses but contains zeros, and it puts the diagnostic information directly into CloudWatch so the actual AlphaESS behaviour can be investigated.
+3. **API drops stale `Live`.** `/status` now omits `Live` and the cutoff times derived from it when the most recent stored reading is older than `liveDataStalenessThresholdSec` (90 s — nine missed 10 s writes is unambiguously broken). With (1) and (2) above, the poller stops writing during AlphaESS quiet hours, so the latest reading ages and this gate fires, surfacing the existing "Awaiting live data" UI state.
+
+**Changes made:**
+
+Defence-in-depth fixes (the bug itself):
+
+- `internal/alphaess/client.go` — add `isNullJSON` and reject null/empty Data in `GetLastPowerData`.
+- `internal/alphaess/client_test.go` — add `TestGetLastPowerData_NullData_ReturnsError` and `TestGetLastPowerData_EmptyData_ReturnsError`.
+- `internal/poller/poller.go` — add `isAllZeroPower`; have `fetchAndStoreLiveData` log + skip on all-zero values; include raw values in the warn-level log line for diagnosis.
+- `internal/poller/poller_test.go` — add `TestFetchAndStoreLiveData_AllZeroPayload_LogsAndSkips` and `TestFetchAndStoreLiveData_ValidSocZeroPower_Writes` (pins the threshold so legitimately quiet readings with valid SoC still persist).
+- `internal/api/status.go` — gate `resp.Live` and the cutoff times on `liveDataStalenessThreshold` (90 s).
+- `internal/api/status_test.go` — `TestHandleStatusStaleLatestReading_OmitsLive` and `TestHandleStatusStalenessBoundary`.
+
+Backfill tooling (repair the rows the outage left behind):
+
+- `internal/dynamo/models.go` — add `NewReadingItemFromSnapshot` (5-min snapshot → `ReadingItem`) and `IsAllZeroReading` (bogus-row detector).
+- `internal/dynamo/models_test.go` — table-driven tests for both helpers (importing / exporting / invalid timestamp / TTL boundary).
+- `internal/alphaess/models.go` — `DerivePower(load, ppv, gridCharge, feedIn) → (pgrid, pbat)` so the live-reading sign convention is pinned in one place; consumed by both `mapDailyPowerToPoints` (past-date Day Detail) and `NewReadingItemFromSnapshot` (the backfill).
+- `internal/alphaess/models_test.go` — `TestDerivePower` exercises importing / exporting / idle / solar-covers-load.
+- `internal/api/day.go` — `mapDailyPowerToPoints` now goes through `alphaess.DerivePower`.
+- `cmd/backfill-readings/main.go` — new one-off CLI that swaps the bogus zero rows for synthetic readings derived from `getOneDayPowerBySn`.
+
+Docs:
+
+- `docs/flux-v1.md` — correct the `getOneDayPowerBySn` row (the prior "Power fields return 0" claim was outdated; `mapDailyPowerToPoints` and the backfill both depend on those fields).
+- `docs/agent-notes/{poller-orchestrator,api-layer,dynamo-layer}.md` — note the new behaviours and helpers.
+- `CHANGELOG.md` — note the fix and the new tool under the next release.
+- `specs/bugfixes/late-night-current-data-gap/{report.md,decision_log.md}` — this report and the decisions for the 90 s threshold + the all-zero-deletion assumption.
+
+**Approach rationale:** Each layer fixes a distinct root cause and reinforces the others. (1) is the cleanest fix when AlphaESS returns a structurally empty response. (2) catches the "structurally valid but all-zero" variant and produces the diagnostic logging the user asked for ("why can't it collect the live data?"). (3) is defence in depth — if a future API quirk slips past (1) and (2), the dashboard still falls back to a correct "no live data" state instead of presenting an aged reading.
+
+**Alternatives considered:**
+
+- *Only fix the API staleness check (the original PR #48 scope).* Rejected once the user clarified that the dashboard was showing all-zeros — staleness alone couldn't explain that, because the rows were fresh, just bogus.
+- *Fall back to the most recent `flux-daily-power` 5-minute snapshot when `getLastPowerData` is bogus, in real time.* Considered but deferred. The 5-minute snapshots are the right shape — the backfill uses exactly them — but the poller refreshes the table only hourly, so the freshest entry can be up to an hour old when the live endpoint goes quiet. That's worse than the existing "Awaiting live data" UI state for a dashboard people open expecting a current reading. Diagnostic logging from (2) will tell us whether a more aggressive real-time fallback (e.g. fetching `getOneDayPowerBySn` on demand when the latest reading ages past the threshold) is worth the API-call budget.
+- *Detect repeated-identical (non-zero) readings.* Rejected — heuristic, false-positives when the system is genuinely steady.
+
+## Regression Tests
+
+**Test files:** `internal/alphaess/client_test.go`, `internal/poller/poller_test.go`, `internal/api/status_test.go`
+
+**Test names:**
+
+- `TestGetLastPowerData_NullData_ReturnsError` — `data: null` becomes a typed error.
+- `TestGetLastPowerData_EmptyData_ReturnsError` — missing `data` field becomes a typed error.
+- `TestFetchAndStoreLiveData_AllZeroPayload_LogsAndSkips` — every-field-zero values do not get persisted; the warn log mentions the all-zero condition.
+- `TestFetchAndStoreLiveData_ValidSocZeroPower_Writes` — a quiet but valid reading (SoC > 0, power fields zero) still persists. Pins the threshold so we don't accidentally suppress real overnight data.
+- `TestHandleStatusStaleLatestReading_OmitsLive` and `TestHandleStatusStalenessBoundary/{fresh,at threshold,one past threshold}` — the API drops `Live` when the latest stored reading is older than 90 s.
+
+**Run command:** `go test ./internal/alphaess/ ./internal/poller/ ./internal/api/ -run 'NullData|EmptyData|AllZeroPayload|ValidSocZeroPower|HandleStatusStale|HandleStatusStalenessBoundary' -v`
+
+## Affected Files
+
+| File | Change |
+|------|--------|
+| `internal/alphaess/client.go` | Reject null/empty Data in `GetLastPowerData`; add `isNullJSON` helper. |
+| `internal/alphaess/client_test.go` | Tests for the null/empty paths. |
+| `internal/poller/poller.go` | Add `isAllZeroPower`; have `fetchAndStoreLiveData` log + skip on all-zero. |
+| `internal/poller/poller_test.go` | All-zero-skip + valid-SoC-writes tests. |
+| `internal/api/status.go` | `liveDataStalenessThresholdSec`; suppress `Live` and cutoff times when latest reading is too old. |
+| `internal/api/status_test.go` | Staleness + boundary tests. |
+| `internal/dynamo/models.go` | Add `NewReadingItemFromSnapshot` (snapshot → ReadingItem) and `IsAllZeroReading` (bogus-row detector). |
+| `internal/dynamo/models_test.go` | Tests for the two new helpers. |
+| `cmd/backfill-readings/main.go` | New one-off CLI that swaps the bogus zero rows for synthetic readings derived from `getOneDayPowerBySn`. |
+| `CHANGELOG.md` | Note the fix and the new tool under the next release. |
+| `specs/bugfixes/late-night-current-data-gap/report.md` | This report. |
+
+## Verification
+
+**Automated:**
+
+- [x] Regression tests pass (`TestHandleStatusStaleLatestReading_OmitsLive`, `TestHandleStatusStalenessBoundary`).
+- [x] `internal/api` suite passes; the only pre-existing failure is `internal/config/TestLoad_MissingRequiredVars/AWS_REGION`, caused by the test relying on `t.Setenv` while `AWS_REGION` is exported in the local shell. Unrelated to this fix; reproduced on `main`.
+- [x] `make lint` passes (`golangci-lint run` reports 0 issues).
+- [x] `go vet ./...` passes.
+
+**Backfill (one-off, after AlphaESS recovers):**
+
+The existing zero rows in `flux-readings` from the most recent outage are still in the table (TTL 30 days). To replace them with real data from AlphaESS's 5-minute snapshots:
+
+```bash
+# Defaults to last 3 days (Sydney TZ). Dry-run first to see the plan.
+ALPHA_APP_ID=... ALPHA_APP_SECRET=... SYSTEM_SERIAL=... AWS_REGION=ap-southeast-2 \
+go run ./cmd/backfill-readings --dry-run
+
+# Apply for real:
+ALPHA_APP_ID=... ALPHA_APP_SECRET=... SYSTEM_SERIAL=... AWS_REGION=ap-southeast-2 \
+go run ./cmd/backfill-readings --from=2026-05-18 --to=2026-05-19
+```
+
+The tool: queries each day's existing readings, identifies the all-zero ones (`isAllZeroReading`), deletes them, fetches `getOneDayPowerBySn`, and writes synthetic 5-minute `ReadingItem`s in their place. Writes are idempotent — re-running is safe. Granularity drops from the live 10 s to 5 min for the backfilled window, which is good enough for the Day Detail chart and recovers `low24h` from picking up bogus 0% lows.
+
+**Manual verification:**
+
+- Once deployed, the next late-evening / overnight window AlphaESS goes quiet, expect:
+  - CloudWatch logs from the poller showing `getLastPowerData: no data in response` and/or `skipping reading write: AlphaESS returned all-zero values` with the raw `ppv/pload/pbat/pgrid/soc` values. These tell us exactly what AlphaESS is sending — confirming whether the issue really is "null payload", "all-zero payload", or both.
+  - The iOS Dashboard switches to "Awaiting live data" with a "—" SoC instead of "0% / 0 W everywhere" within ~90 s of the last good reading.
+  - When AlphaESS resumes (typically by morning), the next successful poll restores the live readout automatically.
+
+## Prevention
+
+- Any future endpoint that surfaces "current" measurements should validate the underlying response is structurally present (`isNullJSON`) and semantically non-trivial (an `isAllZero*` check) before persisting.
+- The new warn-level logs give us per-poll visibility into AlphaESS overnight behaviour. After a couple of nights of telemetry we should know whether `null` or "all-zero object" is the actual response — that informs whether to pursue a 5-min-snapshot fallback for genuine real-time data.
+- Consider a follow-up that emits a CloudWatch metric when the poller skips a live write so sustained outages are visible without waiting for a user report.
+
+## Related
+
+- Transit: T-1274
+- Branch: `T-1274/bugfix-late-night-current-data-gap`
specs/bugfixes/late-night-current-data-gap/decision_log.md New decisions
diff --git a/specs/bugfixes/late-night-current-data-gap/decision_log.md b/specs/bugfixes/late-night-current-data-gap/decision_log.md
new file mode 100644
index 0000000..974b3e6
--- /dev/null
+++ b/specs/bugfixes/late-night-current-data-gap/decision_log.md
@@ -0,0 +1,76 @@
+# Decision Log: Late-night current-data gap (T-1274)
+
+## Decision 1: 90-second staleness threshold for `Live`
+
+**Date**: 2026-05-19
+**Status**: accepted
+
+### Context
+
+`/status` now drops `live` (and the cutoff times derived from it) when the most recent stored reading in `flux-readings` is older than a threshold. The threshold trades off two failure modes:
+
+- **Too tight** (e.g. 30 s): the dashboard flicker-falls-back to "Awaiting live data" during routine transient blips — a single missed poll cycle plus Lambda cold-start jitter is enough.
+- **Too loose** (e.g. 5 min): a sustained outage continues to show aged numbers for longer before the UI tells the user something is wrong.
+
+The poller's normal cadence is `livePollInterval = 10 * time.Second` (`internal/poller/poller.go:17`). Without this gate the user saw aged readings for hours during the AlphaESS overnight outage.
+
+### Decision
+
+Set `liveDataStalenessThreshold = 90 * time.Second` in `internal/api/status.go`.
+
+### Rationale
+
+90 s = nine consecutive missed 10 s writes. Network or AlphaESS hiccups occasionally cost one or two polls; nine in a row is unambiguously an outage. The corresponding "Awaiting live data" state surfaces within a couple of dashboard refresh cycles (10 s each), so the user notices quickly but isn't bothered by transient flicker. The constant is `time.Duration` to match every other timing constant in the codebase (`livePollInterval`, `dailyPowerInterval`, etc.).
+
+### Alternatives Considered
+
+- **30 s threshold**: faster signal — Rejected because a single AlphaESS slow response (10 s timeout + retry) can push past 30 s on a healthy night. Too noisy.
+- **5-minute threshold**: matches typical "stale" intuition elsewhere in the app — Rejected because hours of bogus zeros until the gate fires is the exact failure mode this fix exists to prevent.
+- **Use `live.timestamp` client-side and compute staleness in each app** — Rejected because three clients (iOS Dashboard, macOS Dashboard, widgets) would each need to reimplement the same logic, and the existing widget `StalenessClassifier` already uses a different definition ("when was the *fetch*?", not "when was the reading?"). Centralising in `/status` keeps a single source of truth.
+
+### Consequences
+
+**Positive:**
+- Dashboard truthfully reports "Awaiting live data" instead of presenting hours-old numbers as current.
+- No client changes needed — the existing nil-handling path renders the correct UI.
+
+**Negative:**
+- A brief AlphaESS hiccup of 90–120 s causes a transient "Awaiting live data" state during which the user sees no values. Acceptable trade-off versus showing stale data.
+- The threshold is a tuning knob that may need revisiting if AlphaESS behaviour shifts.
+
+---
+
+## Decision 2: Backfill deletes every-field-zero rows on the assumption they are non-recoverable bogus data
+
+**Date**: 2026-05-19
+**Status**: accepted
+
+### Context
+
+`cmd/backfill-readings` queries each day's existing `flux-readings` rows, identifies the all-zero ones via `dynamo.IsAllZeroReading`, deletes them, and writes synthetic 5-minute rows derived from `getOneDayPowerBySn`. The deletion step is unconditional within the date window — any row whose `ppv`, `pload`, `pbat`, `pgrid`, and `soc` are all exactly zero is treated as the AlphaESS-overnight-outage sentinel and removed.
+
+### Decision
+
+Treat every-field-zero (including `soc == 0`) as the bogus pattern and delete unconditionally.
+
+### Rationale
+
+A functioning battery system that has been running cannot legitimately produce a reading where every field is exactly zero. `Ppv = 0` is normal at night; `Pload = 0` is implausible (a household always has standby draw); `Pbat = 0` is plausible at idle; `Pgrid = 0` is plausible; but `Soc = 0` is impossible in normal operation because the AlphaESS inverter is configured to stop discharge at 5%. The only way every field lands on exactly zero is the unmarshal-from-null-payload path the bug introduced. The same pattern is already used for the daily-energy table (`isAllZeroEnergy` in `internal/poller/poller.go`) on the same logic.
+
+### Alternatives Considered
+
+- **Require `soc > 0` AND at least one power field non-zero before considering a row valid**: Equivalent in practice — every-field-zero implies `soc == 0`, and a real `soc > 0` with all-zero power fields is the "idle but valid" pattern we explicitly preserve. The current check is the simpler form of the same idea.
+- **Use a "last-good before the run" timestamp window instead of a value-based check**: Rejected because there's no reliable signal in the table for "when did the outage start?". A value-based check works on any future occurrence without needing operator input.
+- **Leave the rows in place and let the readers compensate** (e.g. teach `MinSOC` to skip `soc == 0`): Rejected because the corruption is in raw data and is better fixed at source; downstream patches multiply.
+
+### Consequences
+
+**Positive:**
+- Idempotent: re-running the backfill is safe. Synthetic rows always pass `IsAllZeroReading == false` because their `Soc` (from `cbat`) is non-zero.
+- Self-targeting: only the bogus rows are removed; legitimately quiet readings (zero power but real SoC) are untouched (`TestFetchAndStoreLiveData_ValidSocZeroPower_Writes` pins this).
+
+**Negative:**
+- If a future failure mode produces a legitimately every-field-zero state (e.g. a system that has just been powered off, polled at the instant of shutdown), the tool would delete that too. Considered acceptable because the synthetic replacement from `getOneDayPowerBySn` would reflect the same idle state, and a real every-field-zero reading carries no information worth preserving.
+- Couples the helper's semantics to "AlphaESS no-data sentinel"; rename or repurpose would risk callers using it where a real all-zero state matters.
+
+---

Things to double-check

Verify the backfill on a real day before widening the window.

Run cmd/backfill-readings --dry-run against last night's outage window first to confirm the existing / zerosToDelete / syntheticReadingsToWrite counts match expectations. Then apply. The default trailing-3-days range is conservative; explicit --from/--to is safer once you know the outage bounds.

Watch the warn logs the next time AlphaESS goes quiet.

The diagnostic log line includes the raw ppv/pload/pbat/pgrid/soc values. If the fields are all literally zero, layer 2 caught it; if the log doesn't fire at all but the layer-1 error path does, AlphaESS is sending data:null. That tells us whether a future real-time fallback to getOneDayPowerBySn would help.

iOS Dashboard manual verification.

Until the layer-3 staleness gate fires in production, we don't see the 'Awaiting live data' UI exercised via this code path. Worth a manual check on the next quiet evening that the dashboard does flip to it within ~90 s and recovers cleanly when AlphaESS resumes.