Skip to content

release: 1.6.3 — EV charge UX (#277) + today-plan ETAs (#298) + cache-bust fix (#301)#303

Merged
traktore-org merged 21 commits into
mainfrom
develop
May 30, 2026
Merged

release: 1.6.3 — EV charge UX (#277) + today-plan ETAs (#298) + cache-bust fix (#301)#303
traktore-org merged 21 commits into
mainfrom
develop

Conversation

@traktore-org
Copy link
Copy Markdown
Owner

@traktore-org traktore-org commented May 30, 2026

Summary

Version

Maintainer numbered this as a patch (1.6.3) rather than a minor (1.7.0) — the named Charge mode selector carries the same intent as the four removed switches, so existing automations need an entity rename rather than a behaviour rewrite. Manifest bumped accordingly, CHANGELOG and in-narration references updated.

Doc / screenshot sweep

  • README — Charging Modes section rewritten around the 5 modes; cheap-tariff example automation switched from switch.turn_on to select.select_option on charge_mode.
  • EV_CHARGING_LOGIC.md — v1.6.3 banner at top with the new mode table; legacy body kept as historical reference.
  • QUICK_START / SETUP_GUIDE / MULTI_DEVICE_GUIDE — entity tables updated.
  • docs/images/sem_ev_tab.png and sem_home_tab.png recaptured from HA-PROD on v1.6.3. The EV-tab screenshot is the canonical visual proof that the Stale Lovelace cache-bust serves old sem-localize.js after deploy #301 fix lands (label, value, hint all localized).

Behavioural change

The min_plus_solar default is now zone-adaptive during the day — the Min guarantee comes from night charging top-up only, not from forced grid pull at noon. Strict "Min from grid at all times" users should pick always_max. CHANGELOG carries the full migration notes (v4 → v5 → v6 → v7).

Verification

  • 2136 / 2136 tests passing (6 new regression tests for the Stale Lovelace cache-bust serves old sem-localize.js after deploy #301 cache-bust contract).
  • Deployed to HA-TEST and HA-PROD ahead of cut; md5 of __init__.py matches local on both boxes.
  • generate_dashboard call on PROD refreshed every Lovelace resource URL to the content-hash format (verified). EV card screenshot confirms the v1.6.3 selector renders with localized labels.

Test plan

  • All previously-passing tests still pass
  • New TestContentHashCacheBust covers URL format, change-on-edit, stability-when-unchanged, nested dist/ path, missing-file fallback, and an anti-pattern source guard
  • CI green on Tests 3.12, Tests 3.13, Hassfest, HACS Validation, card-test
  • Live verification on HA-PROD — user confirmed the charge-mode card recovered after URL refresh

Auto-closes #277, #298, #301 on merge.

belinea4071 and others added 20 commits May 30, 2026 03:31
Reported on 2026-05-29 PROD soak: ``sensor.keba_p30_max_current``
reads stale after SEM commands ``keba.set_current``. Sometimes by
minutes, sometimes returning values SEM never commanded (e.g. 11 A
after SEM commanded 6 A). The dashboard / diagnostics showed the
KEBA's value as if it were SEM's intent — confusing during
debugging.

Root cause is upstream (the KEBA integration's poll vs the
modbus-write register split — out of SEM's control). What SEM can
do is expose its OWN authoritative ``_current_setpoint`` so users
have a clear "what is SEM trying to do" reading alongside the
upstream "what does the charger think it can do."

Fix:

  ``sensor.py`` — new ``SensorEntityDescription`` per charger:
    ``sensor.sem_charger_<id>_commanded_current`` (Amperes,
    CURRENT device class, MEASUREMENT state class, DIAGNOSTIC
    entity category).

  ``coordinator/coordinator.py`` — wires the publish in the
    per-charger result block. Reads ``ev_dev._current_setpoint``
    (the authoritative SEM-side cache, updated whenever
    ``set_current`` is called) and rounds to 1 decimal.

  ``dashboard/translations.json`` — new ``commanded_current`` key
    across all 15 languages.

  ``dashboard/card/sem-localize.js`` — regenerated (809 keys × 15
    langs).

Usage: trust ``sem_charger_<id>_commanded_current`` for "what is
SEM trying to do." Trust the upstream charger sensor
(``keba_p30_max_current`` and analogues) for "what does the charger
think it can do." When the two disagree by more than one cycle, the
upstream sensor is reading stale.

Suite: 2091 / 2091 (no new tests — this is a pure additive
publish with no behavioural change in the control path; the
diagnostic is meant to be read, not gate logic on).

Closes #291.
Completes the EV-budget unification arc by removing the legacy
fallbacks that v1.6.0 left side-by-side with the canonical path as a
safety net. Carrying two budget formulas alive was exactly the
duplication that produced the disagreement bug class in the first
place; with three weeks of clean v1.6.0/v1.6.1 PROD soak the
fallbacks are dead code, and keeping them invited the next
regression.

No behavioural changes outside the removals — same upgrade path as
any 1.6.x.

Removed
-------
- flow_calculator.calculate_ev_budget       (superseded by canonical)
- flow_calculator.calculate_available_power (zero production callers)
- flow_calculator.calculate_charging_current(both callers moved to
  EVControlMixin._watts_to_amps)
- ev_control._calculate_solar_ev_budget     (74-line legacy fallback;
  actuator now logs error + emits 0 W if _cycle_ev_budget missing —
  fail-safe instead of silently masking init bugs with a divergent
  formula)
- coordinator multi-charger distribution legacy fallback (same fail-
  safe pattern)
- sensor._format_charging_state demotion guard (1a9b3c9 cosmetic
  SOLAR_CHARGING_ACTIVE → ALLOWED downgrade — dead code post-
  unification, verified across v1.6.0/1.6.1 soak)

Tests
-----
- Removed unit tests pinning the deleted primitives directly
  (TestAvailablePower, TestEvBudget, TestEVBudgetSemantics,
  TestAvailablePowerIncludesBatteryDischarge,
  TestAvailablePowerInvariants, TestCalculateSolarEvBudget, the
  budget/current rows from TestEVControlInvariants).
- Their physical invariants (non-negative budget, 16 A clamp,
  battery-discharge inclusion, Zone-3 proportional ramp, measured-
  discharge override) now ride on calculate_canonical_ev_budget and
  the scenario harness.
- 2042 / 2042 unit tests pass.
…test rewrite

Addresses CRITICAL / HIGH / MEDIUM / LOW reviewer findings against the
Phase D.2 cleanup commit:

- CRITICAL: tests/scenario_harness.py was calling the deleted
  calculate_ev_budget / calculate_charging_current inside a bare
  except Exception: pass. After Phase D.2 every scenario assertion
  passed vacuously because calculated_current fell silently to 0.
  Rewrote to compute the canonical EVBudget directly and read
  net_w + current_a from it — scenarios now produce real values
  (verified: surplus-leak scenario first cycle = 6 A / 4500 W) and
  regressions fail loudly.

- HIGH: tests/test_multi_charger_canonical_budget.py mirrored the
  pre-D.2 production branch with the legacy fallback. Post-D.2 the
  branch logs an error and distributes 0 W instead. Rewrote helper
  to match production; added test_missing_cycle_budget_fails_safe_to_zero
  pinning the fail-safe + log; added
  test_legacy_method_attribute_does_not_exist_post_d2 to prevent
  accidental re-introduction.

- MEDIUM: TestEVControlInvariants claimed 16 A clamp coverage but
  didn't assert it. Added test_canonical_budget_current_a_clamped_to_16
  sweeping extreme solar / battery inputs across every non-IDLE
  strategy (including BATTERY_ASSIST which can blow past the
  surplus ceiling by design).

- MEDIUM: ChargingContext.available_power docstring still referenced
  FlowCalculator.calculate_ev_budget(). Updated to point at
  calculate_canonical_ev_budget().net_w.

- LOW: ev_control.py fail-safe comment said "Returning 0 W" but the
  function falls through rather than returning. Reworded.

Suite: 2043 / 2043 green (was 2042; +1 from 16 A clamp test).
Pre-fix: sensor.sem_ev_power only updated on the coordinator's 10 s
cycle. Observed live on PROD 2026-05-29 as a ~5 kW dashboard gap vs
sensor.keba_p30_charging_power for several seconds during EV ramp /
drop transients. Root cause: rate mismatch between SEM coordinator
(10 s) and the KEBA integration's polling (1-3 s).

Post-fix (Option A from #289): the ev_power sensor now ALSO subscribes
to its upstream entities via async_track_state_change_event. On any
upstream change SEM re-sums and pushes the new value immediately.
Resolution mirrors SensorReader._read_ev_power exactly:
  * multi-charger (≥ 2 ev_chargers): sum every charger's
    ev_charging_power_sensor
  * single-charger / legacy: top-level ev_power_sensor with fallback
    to ev_chargers[0].ev_charging_power_sensor

The energy-balance derivations (home_consumption_power, sankey flows)
stay on cycle granularity and self-heal on the next tick — the brief
1-cycle disagreement between sem_ev_power and home_consumption_power
is acceptable per the #289 analysis and corrects without intervention.

11 new unit tests covering:
- Subscription resolution: single / multi / no-upstream / fallback
- Single-charger callback: pushes upstream value; unavailable upstream
  doesn't clear last value; unparseable state is skipped silently
- Multi-charger callback: sums across upstreams; partial unavailable
  sums the valid ones
- Cleanup: removing entity calls the unsub; safe when never subscribed

Suite: 2054 / 2054 green.
v1.6.2: Phase D.2 cleanup (#282) + sub-cycle sem_ev_power (#289)
Phase A of the EV charge UX consolidation arc. Purely additive: the
new selector persists user intent but the coordinator strategy
machine still reads the legacy four-toggle state (ev_charging_mode +
night_charging + tariff_optimized + smart_night_charging). Behaviour
is identical to v1.6.2 until Phase B flips authority to the new
field.

What changes
------------
1. New per-charger entity: select.sem_charger_<id>_charge_mode with
   5 options (solar_only / solar_plus_cheap / min_plus_solar /
   always_max / off). The solar_plus_cheap option is dynamically
   hidden when no dynamic tariff is configured (Q1 resolution).
2. async_migrate_entry v4→v5 derives charge_mode per charger from
   the legacy toggle state. The mapping table and Q1–Q4 decisions
   are documented in docs/plans/2026-05-30_ev_charge_mode_consolidation.md.
3. SEMCoordinator._effective_charge_mode_for(charger_cfg) helper —
   the single read-point Phase B will switch authority on. Returns
   stored value if present, derives from legacy toggles otherwise.
   No production caller reads it yet (Phase A safety property).
4. consts/ev_charge_modes.py: shared EV_CHARGE_MODES dict +
   DEFAULT_EV_CHARGE_MODE. Both select.py and coordinator.py import
   from here so the mode-name set has one source of truth.
5. async_migrate_entry refactor: each step now reads from and writes
   back to threaded accumulated_{data,options} accumulators instead
   of entry.{data,options} directly. Fixes a pre-existing bug
   exposed by the v5 step — sequential steps were re-reading the
   original entry options, losing prior step mutations on test
   harnesses (real HA mutated entry between steps and masked it).
6. config_flow.py: VERSION = 4 → VERSION = 5.
7. 15 languages × translations/*.json: entity.select.charge_mode
   labels + state translations.

Design questions resolved
-------------------------
- Q1 (no-tariff): hide solar_plus_cheap entirely.
- Q2 (battery assist in min_plus_solar): always available, no toggle.
- Q3 (smart night): implicit ON for night-charging modes, toggle hidden.
- Q4 (default for new installs): min_plus_solar.

Tests
-----
- 26 new tests in test_277_charge_mode_phase_a.py covering: derive
  decision tree (10), dynamic options Q1 (4 + current_option clamp),
  full migration loop (5), _effective_charge_mode_for equivalence
  (5), translations completeness (1), Phase B authority hint.
- test_per_charger_seed_migration: version assertions bumped 4 → 5.
- Suite: 2079 / 2079 passing.

(Pre-existing clock-sensitive flake test_reconciliation_when_
integrated_close_to_hardware filed separately as #294 — fails near
the 07:00 local sunrise-based daily-reset boundary. Not caused by
this change.)
…to fixed time

Pre-fix the test depended on wall-clock and broke on two fronts when
a CI run crossed a meter-day boundary (07:00 local):

1. The test seeded the accumulator with one ``today`` value
   computed from ``get_current_meter_day_sunrise_based()`` while
   ``calculate_energy`` looked it up using ``_ev_reset_day`` (which
   post-#279 calls ``get_current_meter_day_offset_based("07:00")``).
   When those two boundary methods returned different dates, the
   lookup missed.

2. Even if (1) was patched, ``_check_rollover`` runs every cycle
   and prunes ``ev_daily_sun_*`` accumulators not matching the
   real-clock today or yesterday. A seeded key for a different
   date got silently deleted, defeating any in-test mock of the
   date computation.

Fix: ``@freeze_time("2026-05-15 12:00:00")`` — noon, far from any
boundary. Both reads see the same date by construction; rollover
matches the seeded key. Use ``calc._ev_reset_day(None)`` to compute
the seed key with the exact method ``calculate_energy`` will use
internally, eliminating the path-divergence risk entirely.

Caught by PR #295 CI run that fired at 06:24 UTC — was filed as
#294 and resolved here as part of unblocking that PR.
…se-a

#277 Phase A: Charge mode selector + v4→v5 migration
Phase B of the EV charge UX consolidation. Routes the night gate
and smart-night-sizing reads through the named charge_mode (the
clean-mapping parts of the legacy four-toggle state) while leaving
``_tariff_optimized_for`` and ``ev_charging_mode`` on the legacy
switches until Phase C rewrites the strategy machine.

Re-scoped from the full plan after a test sweep flagged that two
legacy combinations have no clean home in the 5-mode taxonomy:
  - ``minpv + tariff_optimized=on`` — would silently lose the
    daytime Min-PV tariff pause if collapsed.
  - ``pv`` vs ``minpv`` as the daytime strategy gate — distinct
    zone semantics that flattening would lose.

Authority transferred (mode is now source of truth)
---------------------------------------------------
1. ``charging_control.ChargingStateMachine._read_night_enabled_raw``
   — was reading per-charger ``switch.sem_charger_<id>_night_charging``;
   now derives from the named mode (any charger whose mode permits
   night → gate ON). Pre-EV / no-chargers fallback to the legacy
   global ``switch.sem_night_charging`` preserved for backward-compat
   (Phase C removes).
2. ``coordinator._smart_night_charging_enabled`` — same pattern for
   the per-charger smart-night switch.
3. Inline per-charger night gate at the multi-charger surplus
   distribution loop (``coordinator.py:1085+``).
4. Inline EV preview gate at the "today's plan" daytime fallback
   (``coordinator.py:1640+``).

Authority deferred (still legacy)
---------------------------------
- ``ev_control._tariff_optimized_for`` — unchanged behaviour;
  inline docstring explains the deferral.
- ``coordinator._determine_charging_strategy`` ``charging_mode``
  read — unchanged; inline comment explains the deferral.

New infrastructure
------------------
- ``consts/ev_charge_modes.py`` gains ``effective_charge_mode_for``
  as a module-level free function so ``ChargingStateMachine`` (a
  separate class, not a coordinator mixin) shares the same resolver.
  Plus the predicate sets ``MODE_NIGHT_ALLOWED`` / ``MODE_USES_TARIFF``
  / ``MODE_USES_SMART_NIGHT`` and the ``MODE_TO_LEGACY_CHARGING_MODE``
  adapter (the latter unused in Phase B — Phase C consumes it).
- ``SEMCoordinator`` gains ``_mode_allows_night_charging``,
  ``_mode_uses_tariff``, ``_mode_uses_smart_night`` helpers.
  ``_legacy_charging_mode_for`` was prototyped here but removed
  before commit — Phase C will add it alongside its call site so
  the strategy-machine rewrite ships atomically.

Derivation fix (Phase A bug)
----------------------------
Phase A's ``_derive_charge_mode`` only mapped ``mode == "auto" and
tariff`` to ``solar_plus_cheap``, silently dropping the tariff
signal for ``mode in (pv, self_consumption) and tariff_on``. Phase B
fixes the derivation forward (both ``_derive_charge_mode`` in
``__init__.py`` and ``effective_charge_mode_for`` in
``consts/ev_charge_modes.py``) AND ships a narrow v5→v6 fix-up
migration that re-derives any charger whose stored mode is
``min_plus_solar`` AND legacy mode is ``pv/auto/self_consumption``
AND tariff switch is ON. Explicitly-picked ``min_plus_solar``
without tariff is untouched.

Tests
-----
- New: ``tests/test_277_charge_mode_phase_b.py``, 56 tests covering
  the 5-mode predicate truth tables, end-to-end mode-driven night
  gate via real ``ChargingStateMachine``, ``_smart_night_charging_
  enabled`` mode-driven path, runtime↔migration equivalence (now
  including the pv+tariff fix and the documented deferred
  minpv+tariff combinations), v5→v6 fix-up coverage (correct,
  preserve, multi-charger, no-chargers-no-crash), and source-of-
  truth invariants on the constant sets.
- Updated: ``test_night_charging_debounce.py`` scaffold — the
  pre-Phase-B "one is_state call per cycle" assumption no longer
  holds (resolver makes 1-2 reads). Filter by entity_id so only
  ``_night_charging`` lookups consume the sequence; tariff lookups
  return the factory default. Debounce semantics tested unchanged.
- Updated: version assertions in seed-migration + Phase A tests
  bumped to 6.

Plus dead-code cleanup
----------------------
- Removed dead ``ENTITY_SMART_NIGHT_CHARGING`` import in
  ``coordinator.py`` (never referenced; cleanup folded in since
  Phase B is the PR that retired the last switch read using it).

Suite: 2136 / 2136 passing (was 2080 on develop, +56).

Phase B safety contract: existing PROD installs see ZERO behaviour
change. The new infrastructure is in place for Phase C to swap the
remaining two switches.
…se-b

#277 Phase B: mode-driven authority transfer (re-scoped) + v5→v6 fix-up
…selector

Replaces the legacy four-toggle stack in the per-charger card section
(``ev_grid_charging`` switch + nested ``ev_tariff_mode`` switch + the
dead ``modeLabels`` computation) with a single named ``Charge mode``
selector + a per-mode help line that explains what the selected mode
actually does.

Options come from the HA select entity itself, so ``solar_plus_cheap``
is auto-hidden when no dynamic tariff is configured (Q1). The cheap-
window hint (``Next cheap window …``) is now only shown when the
selected mode actually uses the tariff windows — pre-fix, the line
appeared whenever a tariff was on, even in modes that ignore it.

What changed
------------
- ``dashboard/card/src/cards/sem-ev-status-card.js``:
  - ``_renderChargerSection``: dropped ``ctToggle`` + nightOnLive +
    tariffOnLive + the nested tariff sub-row; added the ``Charge
    mode`` selector with 5-mode labels + per-mode hints; gated the
    cheap-window indicator on ``chargeMode === 'solar_plus_cheap'``.
  - ``render``: removed the dead ``modeLabels`` / ``modeEntity`` lookup
    of the legacy ``ev_charging_mode`` select — its data was never
    rendered.
  - New ``.ct-mode-select`` CSS modeled on the existing ``.ct-unit``
    select so the two selectors visually rhyme.
- ``dashboard/translations.json``: added 11 new keys × 15 languages —
  ``charge_mode``, the 5 mode labels, and the 5 per-mode hints.
- ``dashboard/card/sem-localize.js``: regenerated from translations.json.
- ``dashboard/card/dist/sem-cards.js``: rebuilt bundle.

Verified on HA-TEST
-------------------
- Phase A+B migration ran cleanly: ``charge_mode = "min_plus_solar"``
  (derived from legacy ``pv + night=on + tariff=off``).
- Entity ``select.sem_charger_ev_charger_charge_mode`` registered
  with options ``["solar_only","min_plus_solar","always_max","off"]``
  — ``solar_plus_cheap`` correctly hidden (no dynamic tariff configured).
- Schema bumped to v6.
- Card bundle md5-verified on HA-TEST.

This is the user-visible payoff of the #277 arc. The strategy machine
still reads the legacy ``ev_charging_mode`` + ``_tariff_optimized_for``
switches under the hood (deferred to Phase C per the Phase B PR
narrative); Phase B.2's UI just exposes the named modes to the user.
…se-b2

#277 Phase B.2: per-charger card UI uses the new Charge mode selector
Adds three new ETA rows to the ``sensor.sem_charging_state.attributes
.today_plan`` timeline, surfaced on the Home tab card:

  - Battery full at HH:MM   (when battery is currently charging)
  - Battery reaches floor   (when battery is currently discharging)
  - EV reaches target       (when EV is currently charging)

Each ETA is a best-guess from the CURRENT power rate (same vintage
as the existing ev_min_reached row). Steady-state at the present
rate is a useful approximation for "when does this finish?"

What changed
------------
- ``coordinator/today_plan.py``: three new KIND_* constants, three
  new parameters (``battery_full_eta`` / ``battery_empty_eta`` /
  ``ev_target_eta`` + ``ev_target_kwh`` substitution value).
- ``coordinator/coordinator.py`` ``today_plan`` call site: computes
  each ETA from current PowerReadings (battery_power / battery_soc /
  ev_power / daily_ev_per_charger / target ceilings). Suppresses
  when |rate| < 200 W or SOC is already at the boundary.
- ``dashboard/card/src/cards/sem-today-plan-card.js``: KIND→icon+color
  mapping for the three new kinds (pink for battery-charging,
  teal for battery-discharging, soft-green for EV target).
- ``dashboard/translations.json``: ``plan_battery_full`` /
  ``plan_battery_empty`` / ``plan_ev_target_reached`` × 15 languages.
- ``dashboard/card/sem-localize.js``: regenerated.
- ``dashboard/card/dist/sem-cards.js``: rebuilt bundle.

Tests
-----
- ``tests/test_today_plan.py``: new ``TestLiveETARows`` class, 8
  tests covering each ETA in isolation, simultaneous emission, past
  ETAs suppressed, outside-horizon suppressed, optional values
  substitution, and the default no-op when no ETAs are passed.
- Suite: 2144 / 2144 green (was 2136 on develop, +8 #298 tests).

Closes #298
feat(#298): Today's plan — battery + EV target ETA rows
…oval

The architectural finish of the EV charge UX consolidation arc. The
named ``charge_mode`` is now the sole authority for EV charging
intent — the strategy machine dispatches on it directly,
``_tariff_optimized_for`` derives from it, and the legacy per-charger
switches + ``ev_charging_mode`` select are removed.

Maintainer-resolved Phase C design questions:
- Q1 (``min_plus_solar`` strategy mapping): zone-adaptive (pure ``pv``
  fallthrough). Matches Q4 "zero behaviour change for new installs"
  since the factory default was always ``pv + night=on``.
- Q2 (legacy switches): full removal in this PR (no read-only mirror
  intermediate step).

Mode → strategy dispatch
------------------------
- ``always_max``       → ``("now", …)``
- ``off``              → ``("idle", …)``
- ``solar_only``       → ``_self_consumption_strategy`` (strict surplus)
- ``solar_plus_cheap`` → tariff pause check (was on ``minpv``); then zone
- ``min_plus_solar``   → fall through to pure zone logic

What's gone
-----------
- Per-charger ``switch.sem_charger_<id>_(night_charging|smart_night_
  charging|tariff_optimized)`` — entities + creation code + #255
  reconciliation block + ``smart_night_charging`` from the options
  flow.
- Per-charger ``select.sem_charger_<id>_ev_charging_mode`` —
  entity registration + entity-registry orphan cleanup added.
- Strategy machine's legacy ``self.config.get("ev_charging_mode")``
  read.
- ``_tariff_optimized_for``'s ``switch.sem_charger_<id>_
  tariff_optimized`` read.
- ``effective_charge_mode_for``'s legacy-toggle derivation fallback —
  missing ``charge_mode`` now returns ``DEFAULT_EV_CHARGE_MODE``.
- ``charging_control._read_night_enabled_raw``'s pre-EV
  ``switch.sem_night_charging`` global fallback.
- ``coordinator._mirror_primary_charger_to_global``'s
  ``ev_charging_mode`` mirror entry.
- Orphaned ``strings.json`` + 15-language entity translations for
  the removed switches + select.

v6 → v7 migration
-----------------
Drops ``ev_charging_mode`` from each charger and from the top-level
config. Logs INFO per charger with the removed value.

⚠️ Behavioural change for explicit-``minpv`` legacy users
---------------------------------------------------------
Pre-Phase-C: ``ev_charging_mode=minpv`` users had forced grid-Min
during the day + solar to Max. Post-Phase-C: their ``min_plus_solar``
mode goes through zone-adaptive logic; the Min guarantee comes from
NIGHT top-up only. Documented in the CHANGELOG migration note.

Test sweep
----------
- ``test_277_charge_mode_phase_a.py``: legacy-fallback equivalence
  tests removed (the fallback path is gone); defensive defaults
  tests updated.
- ``test_277_charge_mode_phase_b.py``: ``TestRuntimeMigrationEquivalence``
  renamed to ``TestDeriveChargeModeAllLegacyCombinations`` and
  scoped to migration only. ``test_no_chargers_falls_back_to_
  global_switch`` rewritten to assert no-fallback (the switch is
  gone in this PR). ``test_pre_migration_legacy_falls_back_via_
  derivation`` renamed + comment fixed (was misleading after the
  derivation fallback removal — flagged in review).
- ``test_night_gate_per_charger.py``: replaced with empty-module
  comment (coverage moved to phase_b tests).
- ``test_night_charging_debounce.py``: scaffold rewritten to mutate
  ``charge_mode`` rather than ``hass.states.is_state`` per cycle —
  exercises the production resolver path directly.
- ``test_charging_control.py`` / ``test_dual_state_machine.py``:
  fixtures get ``ev_chargers`` with ``charge_mode``; night-disabled
  tests flip mode to ``solar_only``.
- ``test_ev_deadline_tariff.py``: ``cfg`` dicts get
  ``charge_mode: "solar_plus_cheap"`` for tariff tests; legacy
  ``minpv`` tests rewritten as ``solar_plus_cheap`` /
  ``min_plus_solar`` cases.
- ``test_switch.py`` / ``test_integration.py``: assert
  ``observer_mode`` is the only global switch left.
- ``test_per_charger_seed_migration.py``: renamed test; asserts
  ``ev_charging_mode`` is dropped by v7.
- ``test_scenario.py``: ``test_now_mode_overrides`` →
  ``test_always_max_mode_overrides``.

Suite: 2130 / 2130 passing.

Reviewer pass before commit. Four findings:
- MEDIUM (test name + comment) — addressed in this commit.
- LOW (dead config-flow field) — removed.
- LOW (dead mirror entry) — removed.
- NIT (CHANGELOG) — added [Unreleased] v1.7.0 candidate section.

Refs #277 (Phase C of 3 — architectural finish).
…se-c

#277 Phase C: strategy machine reads charge_mode + legacy switch removal
…298)

Bump version + finalize CHANGELOG header for the v1.7.0 release that
ships the complete #277 arc (Phases A + B + B.2 + C) plus the #298
today-plan ETA rows.

Highlights
----------
- New per-charger ``Charge mode`` selector (5 modes) replaces the
  four-toggle soup (#277 Phase B.2)
- Strategy machine + tariff path now read ``charge_mode`` directly
  (#277 Phase C)
- Today's plan shows live ETAs for battery full / battery floor / EV
  target reached (#298)
- v4 → v7 migration chain (4 sequential schema bumps) seeds the new
  field, fixes a Phase A derivation bug, then retires the legacy
  ``ev_charging_mode`` config key
- 3 legacy per-charger switches + 1 legacy per-charger select
  removed from the entity registry

⚠️ Behavioural change for explicit-``minpv`` legacy users — see the
v1.7.0 CHANGELOG section's "Behavioural change" note.

Suite: 2130 / 2130 tests passing.
The legacy generate_dashboard service path used `int(time.time())` as
the `?v=` cache-bust for sem-localize.js and the other root-level card
files registered in `.storage/lovelace_resources`. The timestamp only
moved on `generate_dashboard` service calls — never on plain rsync
deploys — so when v1.7.0 rsynced a fresh sem-localize.js with the new
charge_mode_* keys, the registered URL stayed put and the browser kept
serving the stale cached file. The new EV charge-mode selector then
rendered raw key strings (`charge_mode`, `charge_mode_min_plus_so…`,
`charge_mode_hint_min_plus_solar`) instead of localized labels.

Fix: replace the timestamp with `{manifest_version}-{sha1(content)[:8]}`,
mirroring the format `_async_register_frontend_resources` already uses
for `dist/sem-cards.js`. Both registration paths now produce identical
URLs for the same file content, so any deploy that changes the file
content auto-flips the URL on the next service call and the browser
cache-misses through to the fresh copy.

Extracted as the module-level `_content_hash_cache_bust` helper so the
behaviour is directly testable. Added 6 regression tests covering
URL format, change-on-edit, stability-when-unchanged, the nested
bundle path, missing-file fallback, and a source guard that the
`int(time.time())` anti-pattern cannot silently return.

Co-authored-by: belinea4071 <belinea4071@users.noreply.github.com>
PR #302 landed on develop after the v1.7.0 release commit but is
shipping in the same release. Adds the Fixed section, the helper
extraction note under Internal, and the Closes #301 reference so
the release notes match what's actually in v1.7.0.
…hot sweep

Renumbers the in-flight release from 1.7.0 to 1.6.3. The entity
removals (`night_charging`, `smart_night_charging`, `tariff_optimized`
switches; `ev_charging_mode` select) are treated as a patch by the
maintainer's policy — the named `Charge mode` selector carries the
same intent so existing automations only need an entity rename, not a
behaviour rewrite.

Doc sweep for the renamed entities so first-touch users land on the
new model:

- README — Charging Modes section rewritten around the 5 named modes
  (Solar only / Solar + cheapest hours / Min + Solar / Always (max) /
  Off). Cheap-tariff example automation switched from `switch.turn_on`
  on `night_charging` to `select.select_option` on `charge_mode`.
- EV_CHARGING_LOGIC.md — v1.6.3 banner at top with the new mode table;
  the legacy toggle-architecture body kept as historical reference for
  migrating users. Embeds the refreshed EV-tab screenshot.
- QUICK_START / SETUP_GUIDE / MULTI_DEVICE_GUIDE — entity tables now
  show `select.sem_charger_<id>_charge_mode` instead of the removed
  switches.
- images/ — sem_ev_tab.png and sem_home_tab.png recaptured from
  HA-PROD on v1.6.3; the EV card screenshot is the canonical visual
  for the new Charge mode selector (label, value, hint line all
  localized — visual proof the #301 cache-bust fix lands).
- CHANGELOG — the prose reference to "v1.7.0 Phase C makes
  min_plus_solar zone-adaptive" reads as "v1.6.3 Phase C" so the
  in-section narration matches the header.
- tests/test_dashboard_generator.py — version literal in the
  `_content_hash_cache_bust` fixtures bumped to 1.6.3 for consistency
  (behaviour unchanged; the helper accepts any version string).

manifest.json: 1.7.0 -> 1.6.3.
2136 / 2136 tests passing.
@traktore-org traktore-org changed the title release: 1.7.0 — EV charge UX (#277) + today-plan ETAs (#298) + cache-bust fix (#301) release: 1.6.3 — EV charge UX (#277) + today-plan ETAs (#298) + cache-bust fix (#301) May 30, 2026
Pre-deploy review for v1.6.3 (#301 PR series) flagged duplicated
``_tariff_pause_warned = False`` calls in the daytime tariff-pause
path — once inside the EXPENSIVE branch, once after it. The review's
read was that the EXPENSIVE-branch clear was a bug; closer reading
shows both clears are inside the ``try``, so both fire only on a
successful ``get_price_level()``. The actual invariant is
``successful read ⇒ flag cleared``, regardless of which price
level came back.

Refactor pulls the clear above the price-level branch so the
invariant is local to one line. Behaviour-identical to the prior
factoring but no longer reads like a price-level bug to future
reviewers.

New ``TestTariffPauseWarningFlap`` covers the contract:
- one warning per provider outage, even across many ticks
- successful read with EXPENSIVE price clears the flag
- successful read with CHEAP price clears the flag
- a recovery + fresh outage re-arms and re-fires

Follow-up cleanups deferred to #304 (select.py orphan-removal
gap for previously-removed chargers) and #305 (dead
_auto_mode_strategy + min_pv mapper branch).

2140 / 2140 tests passing (4 new).
@traktore-org traktore-org merged commit 7d86ae4 into main May 30, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EV charge UX: consolidate solar-mode + night + tariff into one named charge-mode selector

1 participant