Skip to content

fix(output): show History state under a warning, and don't read warnings as charge/export states - #5271

Merged
springfall2008 merged 24 commits into
mainfrom
fix/history-ignore-warning-status
Oct 18, 2026
Merged

springfall2008 merged 24 commits into
mainfrom
fix/history-ignore-warning-status

Conversation

@chalfontchubby

@chalfontchubby chalfontchubby commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Posted by Claude on behalf of @chalfontchubby.

Summary

The History view can show the wrong state for a whole day when a warning recurs every cycle. On one install, a car integration logged out, so car_charging_soc read unavailable and every cycle raised Warn: Return bad float value unavailable from car_charging_soc. The battery force-exported from 16:00 to 19:00 exactly as planned, but History showed HoldChrg from 07:30 to 19:30.

After this change, History shows the state Predbat actually carried out, and marks any slot that ran alongside a warning or error with 🚩. Hovering over the 🚩 names the problem.

Cause

  • record_status(..., had_errors=True) makes the warning the state of predbat.status for the rest of the run. When had_errors is set, record_final_run_status() only logs the run's real state. So a warning that recurs every cycle removes that state from the sensor, and from its history, for as long as the warning lasts.
  • calculate_yesterday() rebuilds past slots from that history by substring. "car_charging_soc" contains "charging", so every slot became a charge window, with its target set to the SoC actually reached. That renders as HoldChrg.

Changes

  1. Warnings are not states. In the History view, a Warn:/Error: status with no state recorded maps to an empty state. The Error: Complete run status X with component errors summary is unwrapped to X.
  2. The state is recorded after the warning. A run that raised a warning ends with predbat.status set to <warning> [<state>], for example Warn: ... [Exporting].
    • The status still starts with the warning. The web dashboard's red row, the gateway (and predbat.com behind it) and user templates checking startswith('Warn') or startswith('Error') behave exactly as before.
    • It is a state change, not an attribute change, on purpose. HA's /api/history/period (queried without significant_changes_only=0) drops attribute-only changes, so a run_status attribute would be lost from history.
    • The run's detail (its target SoC) is shown with its state as on a clean run. The warning's own debug is kept, error_count is not incremented again, and no notification is sent.
    • The midnight tariff comparison runs after this and can record a warning of its own. When it does, the run's state is added after that warning too.
    • A run that stops early without carrying anything out still shows the bare error, so History shows no state for it rather than a stale one.
  3. History splits state from warning.
    • The state is taken from the trailing bracket. The brackets are matched from the end, because a state can carry brackets of its own (Demand [Freeze exporting], Charging [Alert]).
    • The bracket only counts as a state when it starts with a known run state (RUN_STATE_NAMES in const.py), because warning text can end in brackets that are not a state, such as an interpolated list.
    • The state is then rebuilt as before, so force and freeze exports stay distinct. The warning becomes a 🚩 on the state cell and a status_warning reason after the slot's own reason.
  4. The state survives the 255-character clamp. Home Assistant rejects longer states. The warning is shortened instead of the whole string, so the [state] suffix is never cut off.
  5. Flags match the rebuilt state. A slot's first and last few minutes can still carry the previous or next slot's status, and History ignores those minutes when rebuilding the state. A warning from those minutes is ignored too, so the 🚩 only names problems from the minutes the shown state was rebuilt from. record_final_run_status() and the History parser share the component-error summary's wording through const.py.
  6. Less flip-flopping. A mid-run warning whose text repeats the one already shown keeps the [<state>] suffix. Without this, a single recurring warning would change the sensor's state twice every cycle (bare warning, then with its state).
    • Two different recurring warnings, or one whose text changes each cycle, still change state within a cycle, as they already did on main.
    • A state trigger with no to: still fires every cycle on the attribute updates, also as on main.
    • Predheat's Warn - ... is recognised as a warning too.
  7. The plain state as an attribute. predbat.status also carries the state the last completed run carried out as its run_state attribute (Exporting, Demand, Hold for car), with no warning text, for automations and the gateway.
    • It is kept while a warning is shown part way through a run, and when a run stops before executing, as the inverter is still following it. It is empty after a restart until the first run finishes.
    • The History view still decodes the state from the sensor's text, because some recorder databases don't keep attribute history.
  8. Docs. output-data.md and the status list in what-does-predbat-do.md describe the Warn: ... [<state>] form; output-data.md also lists the run_state attribute.

Behaviour changes to be aware of

  • While a warning is active, predbat.status reads Warn: ... [Exporting] instead of just Warn: ....
    • Anything that checks how the status starts, or whether it contains Warn/Error, is unaffected.
    • A template comparing one exact warning (== 'Warn: ...') will no longer match.
  • History shows the state Predbat commanded, even when the warning says the command failed (for example Warn: ... No entity_id for charge_start_time [Charging]). The 🚩 and its reason make this visible.
  • Automations that follow the state (the LuxPower freeze automations, Victron route C) see the warning first, exactly as on main, so their docs are unchanged.
  • The charge-curve learner in inverter.py splits predbat.status on the first comma. It still skips cycles that raised a warning, as on main.
  • On the first update, History recorded before this change has no state for warning slots, which show blank with 🚩. That clears within the two-day window.
  • predbat.status gains a run_state attribute. Automations should prefer state_attr('predbat.status', 'run_state') to matching the status text.
  • execute.py now takes its run state names from const.py (RUN_STATE_*), the same constants RUN_STATE_NAMES is built from, so the History parser can't drift from what is written. The strings themselves are unchanged.

Left as-is (for discussion)

record_status() and record_status_under_warning() share a lock, because component threads such as GE Cloud call them alongside the main loop. update_pred() clears the run's warning state under it too.

  • The lock does not cover record_final_run_status()'s choice between the two, which reads had_errors outside it. A component warning that lands between that read and the write is overwritten until the next run, as on main.
  • The status-change notification is sent after the lock is released, because over the websocket it can wait up to two minutes on another thread.

A component thread can set had_errors via non_fatal_error_occurred() without recording a warning. In that case predbat.status keeps the last clean state, as on main. The comments in component_base.py and solis.py rely on this. Changing it would put an error on the status for every cycle with a transient cloud-API failure, so I've left it alone.

Across a gap in the recorder, a warning is held along with the state it arrived with, because both come from the same forward-filled series. The 🚩 therefore always describes the status the cell was rebuilt from.

Tests

  • Splitting status strings into state and warning: the bracket suffix, including states with brackets of their own ([Demand [Freeze exporting]], [Charging [Alert]]). Warnings ending in their own brackets, with and without a state after them. Unbalanced brackets. The component-error summary. The Predheat form, bare and with a state.
  • History rebuild: from a warning that stays all day, a transient warning, the bracketed form, freeze under a warning, and a bare warning. Also the exact plan minutes a one-hour warning flags, and a warning lagging into the next slot's edge flagging only its own slot.
  • record_status_under_warning: the state recorded after the warning, kept debug, no double-counted error_count, and the run's detail shown with its state. A repeated mid-run warning keeps its suffix and detail. A different error is shown bare. A component-only had_errors leaves the sensor alone.
  • Run state names: every RUN_STATE_* constant, and the qualified forms execute.py builds (Demand (Holiday), Demand [Freeze exporting], Charging, Hold for iBoost...), reads back from behind a warning. The test fails if one is missing from RUN_STATE_NAMES, which I checked by dropping Calibration from the list.
  • record_status_run_state: the attribute before the first run, after a warned run, through a repeated mid-run warning, after a run that stopped early, after a clean run and after a component error.
  • The clamp: a long warning is shortened so its [state] suffix stays within 255 characters, both at the end of the run and when the warning repeats mid-run.
  • History row: the 🚩 and its reason.
  • record_status_concurrent: 40 warnings from 4 threads are all counted, and the notification is sent with the lock released. The component health test also puts the real component-error summary through the History parser.
  • record_status_reset_locked: the start-of-run reset waits for the status lock.
  • record_status_after_comparison: a warning the tariff comparison records ends up with the run's state and detail after it.
  • ./run_all --quick passes, and pre-commit passes on the changed files.

🤖 Generated with Claude Code

@springfall2008 springfall2008 added the BOT_REVIEW Trigger an autotriage label Sep 28, 2026
Comment thread apps/predbat/output.py Outdated
Comment thread apps/predbat/output.py Outdated
Comment thread apps/predbat/output.py
Comment thread apps/predbat/output.py
Comment thread apps/predbat/output.py
Comment thread apps/predbat/output.py Outdated
Comment thread apps/predbat/output.py Outdated
@springfall2008 springfall2008 removed the BOT_REVIEW Trigger an autotriage label Sep 28, 2026
chalfontchubby and others added 8 commits September 28, 2026 14:45
A warning or error recorded with had_errors pins itself on predbat.status for
the rest of the run, and update_pred() then only logs the real run status. A
warning that recurs every cycle - "Return bad float value unavailable from
car_charging_soc" after a car integration logs out - therefore replaces the
recorded state for as long as it lasts.

calculate_yesterday() classified statuses by substring, so "car_charging_soc"
matched "charging" and a day of force exports was shown as HoldChrg, with the
target set to the SoC actually reached. Map Warn:/Error: statuses to an empty
state that matches neither side, unwrapping the component-error summary to the
run status it carries.

Hours covered by a pinned warning now show no state rather than a wrong one;
preserving the real run status alongside the warning is a separate change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A warning or error recorded during a run takes over predbat.status, and the
had_errors branch of record_final_run_status() only logged the state the run
executed. A warning that recurs every cycle therefore erased that state from
the sensor, and from its history, for as long as it lasted.

End such a run by putting its state in front of the warning, in the same
comma form as ", Hold for car": "Exporting, Warn: ...". The state changes
with either half, so Home Assistant's history keeps it; attribute-only changes
would be filtered out. The warning's own debug is kept, error_count is not
counted again and no notification is sent. A run whose had_errors came from a
component thread with no warning recorded leaves the sensor as before, and a
run that bails out early keeps its bare error, since nothing was executed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…flag

Split each historical predbat.status into the state it records and the
warning it carries. "Exporting, Warn: ..." now rebuilds as the export it was
(force and freeze exports stay distinct) instead of being matched by
substring against the warning text, and the slot is flagged with a red flag
in the state cell plus a status_warning reason naming the problem, after the
slot's own reason.

A bare warning, from a run that executed nothing or from history recorded
before the state was kept, still rebuilds as no state, and is flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A warning recorded mid-run was written bare, then with the run's state in
front of it at the end, so a warning that recurs every cycle changed the
status sensor's state twice a cycle. A warning whose text repeats the one
already shown now keeps that state in front of it. Any other warning or error,
including a run bailing out without executing, is still shown bare - so two
different recurring warnings, or one whose text changes each cycle, still
change state within a cycle, as they did before.

Also stop reading Predheat's bare "Warn - ..." as a charge/export state in the
History view, and document the status format and the History red flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…istory parser

split_status_warning() re-stated the "Error: Complete run status ... with
component errors: ..." literal that record_final_run_status() builds, in a
different file. A wording change on either side would have silently lost
the run's state from every History slot recorded under a component error.
Both now read the prefix and suffix from const.py, and the component health
test round-trips the real summary through the parser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est-only helper

split_status_warning() knew Predheat's bare "Warn - ..." form but not the
same warning behind a state, so "Exporting, Warn - ..." would have been read
as one state and lost its flag. The marker list now matches the bare list.

yesterday_status_core() had no production caller once calculate_yesterday()
moved to split_status_warning(); the test now calls split_status_warning()
directly and checks the warning half too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…esn't trust

The slot reconstruction ignores the first or last five minutes of a slot
when they don't hold one state, as that is the previous (or next) slot's
status lagging the boundary. The warning flag scanned the whole slot, so a
warning that lagged in with that leftover flagged a slot whose shown state
was rebuilt without those minutes. Warnings in an untrusted edge are now
dropped along with the status they arrived with.

Also notes why a warning is held across a recorder gap: it is held with
the same forward-filled status the slot's state is rebuilt from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Component threads (GE Cloud, Solis, ...) call record_status() alongside the
main loop. The error_count attribute is a read-modify-write of the sensor's
own state, so two concurrent calls could both read N and both write N+1.
record_status() and record_status_under_warning() now share a status lock,
which also keeps each call's current_status/status_warning update whole.

It does not cover record_final_run_status()'s choice between them, which
reads had_errors outside the lock: a component warning landing between
that read and the write is still overwritten until the next run, as
before this change.

The status-change notification is sent after the lock is released: over
the websocket it waits on another thread for up to two minutes, which would
otherwise stall every other status write. The lock is kept out of the debug
dump, which serialises PredBat's attributes and can't pickle a lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chalfontchubby and others added 2 commits October 11, 2026 11:11
split_status_warning() tried ', warn:' before ', error:' and so on, rather
than taking whichever came first. A status like 'Charging, Error: ... , Warn: ...'
was cut at the later marker, so the flag's tooltip showed only the tail of
the error and the state part kept the error text. Take the minimum index
across all markers instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reeze automations

With this branch, a run that raised a warning ends with predbat.status set to
'<state>, Warn: ...'. That gets past the Freeze Charge Exit automation's
Warn:/Error: filter, and its exact match on 'Hold charging' then fails, so
the default branch turns AC charge off during a hold. Match with startswith,
as the same automations already do for 'Charging' and 'Freeze charging'.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

chalfontchubby and others added 5 commits October 12, 2026 10:19
…rning-status

Conflicts resolved:
- apps/predbat/const.py: kept both new constants - this branch's
  COMPONENT_ERROR_STATUS_PREFIX/SUFFIX and main's REPEAT_FULL_LOG_SECONDS
  (#5428).
- apps/predbat/predbat.py: const import takes both sides' additions.
- apps/predbat/output.py: const import keeps this branch's multi-line form
  plus main's new RUN_EVERY (used by history_slot/history_refresh_minutes).
  The calculate_yesterday() publish_html_plan call auto-merged with both
  status_warnings and main's refresh_minutes.
- docs/inverter-setup.md: took main's reformatted LuxPower freeze
  automations and re-applied this branch's four 'Hold charging' exact
  matches as startswith('Hold charging').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tatus stubs

main now sets plan_json["refresh_minutes"] on both plans calculate_yesterday()
builds, so the stubbed plan_write_debug/publish_html_plan must return a dict
like the other stubs in this file, not the string "{}".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Route C (merged from main) has a Node-RED flow follow predbat.status by
exact value. With this branch a run that raised a warning records
'<state>, Warn: ...', which an exact match sends to the catch-all row -
the same trap the LuxPower freeze automations were changed for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
update_pred() cleared had_errors and the recorded warning without taking
status_lock. A component thread recording a warning between the two resets
left had_errors set with the warning text cleared, so the end of the run
could not put its state back in front of that warning and the sensor kept
the bare warning until the next run. The resets now live in
reset_run_status(), which takes the lock as record_status() does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
record_status_under_warning() put the run's state back in front of the
warning but wrote an empty detail, so the target SoC that a clean run
shows (dashboard and gateway read the sensor's detail attribute)
disappeared for as long as a warning recurred. It now takes the run's
status_extra and writes it as the detail, as a clean run does.

A repeated mid-run warning that keeps the previous state in front of it
now keeps that state's detail too. The check reads the sensor state as
last written (status_state) rather than current_status, which carries
the detail on the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
chalfontchubby and others added 9 commits October 15, 2026 21:53
…rning

The midnight tariff comparison runs after record_final_run_status() and
re-reads sensors and rates, so it can record a warning of its own (a bad
rate in a compared tariff, say) or a different one than the run raised.
That warning took over predbat.status bare until the next run, so the
sensor showed no state - which a status-following automation reads as
"anything else". run_comparison() now takes the run's status and, when
the comparison changed the sensor, puts that state and its detail back
in front of the warning, as for a warning raised during the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watchdog cancels Freeze charging if predbat.status does not start
with 'Freeze charging' 30 seconds after the guard is armed. A warning
recorded part way through a plan update is shown bare until the run puts
its state back in front of it, so a watchdog check landing in that window
cancelled a freeze Predbat was carrying out. It now skips a bare
Warn:/Error: status (and unknown/unavailable), as the exit automation
already does; the state written after the warning changes the status, so
the exit automation still cleans up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onent error

While a component is failing, predbat.status reads "Error: Complete run
status <state> with component errors: ..." at the end of every update,
not just for a moment. Ignoring it as a bare Error: meant a freeze that
Predbat had turned into Hold charging was never cancelled: the exit
automation ignores Error: statuses too, and the status does not change.
The watchdog now takes the state out of that form before checking it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The clamp and concurrency tests reset current_status but not the
status_state/status_detail pair that record_status() now checks for a
repeated warning, so they left them changed on the shared PredBat
fixture for whichever test runs next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very check

The previous commit read the state out of the component-error status for
the abort check only. The AC and SoC steps after it still tested the raw
status, so with a component failing a watchdog that now got past the
abort turned grid AC charging off and left the freeze charge level in
place while Predbat reported Charging or Hold charging. The state is now
read out once into a variable and every check uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
utils.py: the debug-dump exclusion list keeps both this branch's status_lock
and main's io_slot_log_gate (#5569), with the comment covering both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… brackets

predbat.status for a run that raised a warning is now "Warn: ... [Exporting]"
rather than "Exporting, Warn: ...". The status keeps starting with the warning,
so the web dashboard, the gateway (and predbat.com behind it) and user templates
that check startswith('Warn') or startswith('Error') behave exactly as before;
only the History view reads the state back out.

- split_status_warning() takes the state from a trailing bracket matched by
  balance from the end, as the state carries brackets of its own ("Demand
  [Freeze exporting]", "Charging [Alert]"), and only when it starts with a
  known run state (const.RUN_STATE_NAMES) - warning text can end in brackets
  that are not a state, e.g. an interpolated list.
- The 255-character clamp shortens the warning, never the state suffix.
- A warning repeating mid-run still keeps the suffix, matched by parsing the
  shown status rather than by its ending.
- The LuxPower and Victron route C doc changes are reverted: with the warning
  first, status-following automations behave as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…istory parser

execute.py wrote its run states as string literals, and RUN_STATE_NAMES - the
list the History view uses to tell "Warn: ... [Exporting]" from a warning that
merely ends in brackets - repeated them by hand, so a new or renamed state in
execute_plan() would silently read as no state in History during a warning.

- const.py defines each run state once (RUN_STATE_*); execute.py assigns and
  compares those, CHARGE/EXPORT_STATE_PRECEDENCE and RUN_STATE_NAMES are built
  from them. The strings are unchanged.
- A test reads back every RUN_STATE_* constant, and the qualified forms
  execute.py builds, from behind a warning, and fails if one is missing from
  RUN_STATE_NAMES (checked by dropping Calibration from the list).
- what-does-predbat-do.md lists the "Warn: ... [state]" form with the other
  status values.
- The tariff comparison test restores previous_status and stubs call_notify,
  which its clean-run case changed on the shared fixture; two TEST_REGISTRY
  descriptions still described the old comma format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tate attribute

predbat.status reads "Warn: ... [Exporting]" while a warning is active, so
anything following Predbat's state had to parse it out of the text. The sensor
now also carries the state the last completed run carried out, with no warning
text, as its run_state attribute - "Exporting", "Demand, Hold for car".

It is set when a run completes (clean, under a warning, or with a component
error) and kept until the next one does, so a warning part way through a run
or a run that stops before executing leaves it on the state the inverter is
still following. It is empty after a restart until the first run finishes.

The History view still decodes the state from the sensor's text, as some
recorder databases don't keep attribute history; this is for live readers
such as automations and the gateway.

Tests that run record_final_run_status() now restore status_run_state on the
shared fixture.

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

@springfall2008 springfall2008 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Reviewed at d09f8b3: no issues found.

  • History reads the run's state back from Warn: ... [<state>]. It only accepts a balanced trailing bracket that starts with a known run state, and the 255-character clamp keeps that suffix.
  • execute.py takes its run state names from const.py, the same constants the History parser checks against. A test fails if the two drift apart.
  • predbat.status gains a run_state attribute for live readers. History still decodes the state from the text, as some recorder databases don't keep attribute history.
  • CI passes, and the branch merges cleanly with main.

@springfall2008
springfall2008 merged commit d17f0e0 into main Oct 18, 2026
2 checks passed
@springfall2008
springfall2008 deleted the fix/history-ignore-warning-status branch October 18, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants