diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..1d5f9954 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## What + + + +## Approved roadmap item + +Discussion / issue: # + + + +## Tests + + diff --git a/.gitignore b/.gitignore index b52785da..3ffd73cf 100644 --- a/.gitignore +++ b/.gitignore @@ -42,9 +42,6 @@ feature-requests-*.md feature-requests-*.json feature-poll-draft-*.md -# Project-local Codex skills -/.agents/skills/ - CLAUDE.md .agents/scripts/publish_release.py .agents/workflows/publish_release.md @@ -64,5 +61,6 @@ design_handoff_mvem_dashboard/ MULTI_BATTERY_MIGRATION_PLAN.md ROADMAP.md bash.exe.stackdump -.codex/github-review/ffunes--omnibattery.json +.agents/ +.codex/ resumen-2026-08-25.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba739a1..5991bb5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,157 +1,63 @@ # Changelog -## [1.4.0b8] - 2026-08-30 - -### Fixed - -- **A midnight predictive slot could announce a charge it never made**: a slot opening at 00:00 read the solar provider's exhausted previous-day remainder, invented a deficit and notified "STARTED"; a zero remaining forecast is now retried within the existing grace, and any re-evaluation that reverses the decision replaces the notification instead of leaving it on screen. - -- **Daily Operation painted grid charge over a day that needed none**: the informational 00:05 cheap-hour calendar (`charging_needed=false`) is no longer projected, so slots the runtime never activates stop showing a full-power grid quota on top of the solar forecast. -- **Daily Operation reported every grid charge as an unknown decision**: the timeline normalizer rejected its own canonical `scheduled` value, which also stopped grid charging being inferred from charge power and suppressed the dynamic-price context marker. -- **Daily Operation collapsed the elapsed part of the forecast curve**: the interval in progress is projected with only its remaining energy, and that shrinking remnant was frozen as the interval's forecast when it closed. The series now publishes the full quarter while the battery simulation keeps the prorated value. -- **Daily Operation dipped to zero at the now marker every quarter of an hour**: an open cell with under a minute of coverage was plotted as a raw partial instead of being omitted, notching both the observed and forecast lines. -- **A single zero-production quarter erased the rest of the day's solar forecast**: an inverter restart, curtailment or failed PV read at midday was enough to trip the post-sunset curve removal, which now needs a sustained run of observed zeros. -- **Daily Operation warned about a missing solar forecast every night**: the learned profile legitimately has no future energy once the sun is down, but the notice was shown anyway. Both no-sun-left reasons are now silenced outside the producing window, and a zero forecast budget is surfaced during the day instead of being hidden unconditionally. -- **Weekly Full Charge could override a normal predictive grid-charge target**: an overlapping weekly cycle now only permits later solar charging toward 100%; while predictive charging owns the session, its per-battery target remains authoritative and grid import stops at the planned quota. -- **Daily Operation could show Hourly Net Balance visuals while the feature was disabled**: the live switch now gates its legend and cause markers, while diagonal overlays for genuinely simultaneous solar and battery actions remain visible as coexistence indicators. -- **Backup-port discharge omitted from energy totals and round-trip efficiency** (#321): the shared energy layer can now integrate supplemental discharge declared through semantic driver hooks, keeping accumulation, persistence, total/daily discharge, calculated cycles, RTE and entity representation brand/model agnostic while each driver decides whether and how the correction applies. The Marstek driver enables it for the confirmed Venus E v3 case, counting fresh off-grid output only in Backup Mode and excluding grid passthrough power. -- **Dynamic Power Control could compete with an active Solar Surplus wallbox** (#371): residual monitoring now re-yields when the wallbox loses at least 200 W of available solar margin, while equal solar and wallbox reductions leave residual battery charging available. - -## [1.4.0b7] - 2026-08-28 - -### Fixed - -- **Venus A/D installations can now declare whether panels are connected** (#361): battery setup now distinguishes available MPPT hardware from the installation topology. Panel-equipped installations retain the AC+MPPT flow model; installations with unused MPPT inputs keep AC power as the battery-flow source without contributing MPPT capability to solar totals. Existing Venus A/D entries migrate with panels enabled to preserve their current behaviour. -- **Dynamic Pricing could lose deadline-aware planning at the daily evaluation**: chronological solar boundaries now preserve the runtime price slots' datetime-awareness contract, preventing naive/aware comparison failures when day-ahead prices are not yet published. Charge Delay also reuses unchanged price parsing within each quarter instead of reparsing the same sensor several times per control cycle. -- **Daily Operation could compress the remaining solar forecast into the next hour after Charge Delay was disabled**: its solar window now derives both boundaries from the same observed or astronomical start instead of reusing Charge Delay's persisted start and active-charging `now + 1 hour` extension. A residual marker can no longer move or inflate the dashboard forecast, including when Charge Delay stays disabled across midnight. -- **Solar Charge Delay could permanently unlock at the midnight forecast rollover**: it now uses the shared normalized remaining-solar input, preserves dated provider periods, and treats a scalar zero before solar hours as transient through the bounded forecast grace rather than latching a false `low_forecast` day. Legacy whole-day sensors remain converted once, without a second production subtraction or curve transform. -- **Solar Charge Delay status could retain stale forecast details while charging to its SOC setpoint**: obsolete balance and unlock values are cleared, the configured `soc_setpoint` is published, and Daily Operation now exposes projected setpoint/unlock milestones separately until a runtime delay clock is available. -- **Daily Operation could project the Charge Delay unlock at the first solar deficit instead of the configured safety boundary**: setpoint projections now include the final SOC target, solar end time and safety margin, matching the runtime unlock estimate. -- **Daily Operation could drop a projected Charge Delay clock when the status carried `estimated_unlock_time: null`**: projection data now replaces an absent/null clock instead of using `setdefault`. -- **Solar Charge Delay could erase the yellow solar-window background in Daily Operation**: delayed intervals continue to block projected battery charging and remain marked with their unlock clocks, while any underlying solar surplus is again shown as a yellow opportunity instead of being forced to neutral; green remains reserved for solar energy actually assigned to the battery. -- **Daily Operation could claim that a mature solar profile was still learning every night**: an empty post-sunset progress range now preserves the profile's global maturity, and a normal zero remaining-solar budget takes precedence over irrelevant candidate fallback reasons so the dashboard no longer shows a misleading alternative-estimate warning. -- **Daily Operation could show Charge Delay clocks after the feature was disabled**: the timeline DTO now preserves the live enabled state and the dashboard requires it before rendering delay markers, suppressing residual stored boundaries without breaking older payloads. -- **Dynamic Pricing evening re-evaluation could crash with a `NameError`**: late-day deficit scheduling now reads the asynchronous dynamic base consumption directly from the tracker, so evening recharge can complete normally. -- **Selecting an OmniBattery solar output as the external production sensor could create runaway totals**: setup and options now reject aggregate or per-battery solar entities created by OmniBattery, including renamed and legacy entities. Existing circular configurations are also ignored at runtime, preventing the aggregate solar value from feeding itself and inflating both solar production and derived home consumption on every update. - -## [1.4.0b6] - 2026-08-27 - -### Changed - -- **Recorder backfills now use bounded local-day queries under one entry-owned coordinator**: household, legacy consumption, solar-profile and daily-counter recovery work is serialized, observes DST-aware 23/24/25-hour boundaries, checkpoints completed days and skips profile coverage already stored. Historical reconstruction no longer blocks integration setup, and generation guards prevent an unloaded runtime from applying late query results. -- **Daily Operation timeline updates are cached, push-only and coalesced**: public snapshots are rebuilt only after material revisions, the date state no longer constructs the full DTO, continuous activity is published at most every 15 seconds, and Store persistence uses a 60-second debounce with immediate flushes for interval closure, rollover, structural plan changes and unload. -- **Battery coordinators now have one polling owner**: the redundant explicit 1.5-second refresh timer was removed in favor of `DataUpdateCoordinator`, with bounded refresh/backfill/timeline counters added to diagnostics. Control, pricing, persistence and notification tasks are tied to the config-entry lifecycle and cancelled before hardware teardown. - -### Fixed - -- **Daily Operation could project solar charging while Charge Delay was still active**: when the delay SOC setpoint was enabled and already reached, the future projection dropped the delay boundary, omitted its clock markers and assigned solar surplus to the battery before the estimated unlock. Delayed quarters now remain neutral and non-charging, retain their unlock clocks, and prorate only the portion of the boundary quarter that occurs after release. -- **Venus A/D multi-pack charging could stop after a later pack handover** (#350): a sustained accepted 200 W retry now rearms the provisional BMS-cutoff detection for the next coupled pack, while a retry followed by another confirmed refusal still completes charging. -- **Saving options could overlap historical work from the old and new integration runtimes**: Options Flow marks its pending reload before updating the entry, the old live-update listener ignores that transition, and unload invalidates and awaits background generations before platform or battery shutdown. -- **Recorder backfill could still pause Home Assistant while binning a high-cadence day**: detached state lists are now converted into 15-minute bins in the executor while unload waits for that bounded processing to release its memory. A synthetic 86,401-state day that previously occupied the event loop for about one second now remains off the control path. -- **Partial profile coverage could be reused as an understated legacy daily total**: profile-derived totals now require every physical local-day interval, while spring and autumn DST days retain their real 23/25-hour energy. Current-day accumulator migrations always use the bounded Recorder query instead of dropping an open interval or earlier gaps. -- **Reloading could discard a pending Charge Delay or Weekly Full Charge state save**: entry-owned persistence tasks are cancelled first, then both managers synchronously flush their final coherent latch and restore state before teardown. -- **Venus D firmware below EMS 149 could reject 2500 W power limits**: setup, runtime capabilities and battery number entities now derive their maximum from the detected EMS firmware. Venus D units below 149 are limited to 2200 W; firmware 149 and newer retain the 2500 W ceiling, and unknown firmware starts conservatively at 2200 W until detected. -- **Daily Operation could show no projection in Dynamic Pricing mode**: price calendars store local wall-clock slots without timezone metadata, while the dashboard refresh uses timezone-aware Home Assistant timestamps. The read-only projection now aligns private slot and deadline copies with the dashboard timezone without modifying the executable charging schedule. -- **Anker Solarbank Max AC could double-count solar production** (#340): model-specific PV handling no longer exposes or aggregates the derived `solar_power` value for Max AC (`DMWH`/`DMXU`/`E25H`) or XE AC models, so the configured external solar sensor remains the only source. Solarbank 4 E5000 Pro (`DN7M`/`DPM4`) retains its internal aggregate PV behavior, with unknown models following the safe external-only fallback. - -## [1.4.0b5] - 2026-08-21 - -### Added - -- **Huawei SUN2000 + LUNA2000 driver**: adds native Modbus telemetry and optional service/direct-write control for Huawei hybrid inverters with LUNA2000 storage. Thanks to @sphings79 for the contribution. -- **Off-grid meter mode**: an optional second W/kW power sensor, with its own inverted-sign setting, now exposes a Home Assistant switch and dashboard control that change the active source used by PD control and derived consumption/grid statistics. The switch is software-only and never enables or changes a battery's physical off-grid/EPS port. - -- **Daily operation timeline in the dashboard**: the Summary tab now shows a 24-hour, 15-minute resolution chart with observed and projected battery actions, solar and household energy curves, and total SOC. -- **Vacation Mode dashboard switch**: a new control in the common system card pauses household-consumption learning and the legacy daily average while physical metering and battery control continue normally. Predictive features use a constant baseline learned from 01:00–05:00, taking the median of up to the last three nights with at least three hours of coverage; vacation intervals remain excluded from Recorder backfill after the mode is disabled. - -### Changed - -- **Multi-battery control was limited to six batteries**: configuration now supports up to ten independently controllable batteries, with per-battery time-slot fields and a responsive dashboard grid that keeps larger fleets' cards usable. -- **Daily Operation projection is now isolated from predictive control state**: the chart uses immutable simulation requests and a read-only cross-midnight adapter, while the executable planner is structurally limited to the current local day and canonical diagnostics are restored by the predictive startup lifecycle instead of a dashboard refresh. - -### Fixed - -- **Dashboard sliders could jump back while being adjusted on a phone**: touch interaction is now tracked explicitly instead of relying on browser focus, and the submitted thumb position is held briefly until Home Assistant confirms the new value, so frequent state updates no longer interrupt fine adjustments. -- **Dashboard chart clocks could ignore Home Assistant's 12/24-hour preference** (#266): hover times and zoomed clock labels now follow the user's Home Assistant time-format setting instead of falling back to the UI language's AM/PM convention. -- **Huawei cascade reconfiguration could pair telemetry and commands with different inverters**: validates the selected Huawei device against the probed inverter serial before applying the reconfiguration. -- **A valid provider solar timeline could be labelled as an alternative estimate because an unused learned profile was rejected**: fallback notices now describe only higher-priority sources that actually failed before the selected source, while full fallback still retains its concrete diagnostic reasons. -- **Predictive Time Slot diagnostics could remain `unknown` after reloading the integration once the day's final charging window had ended**: Time Slot now performs its daily energy-balance evaluation after 00:05 even when no configured windows remain, restoring stored/usable energy, consumption, deficit and decision attributes without creating a false active charging calendar. -- **Learned household-consumption profiles could include almost all excluded-device demand after Recorder backfill**: external-load energy is now normalized by matching interval coverage without an erroneous second hours conversion, so full and partial exclusions—and additional loads—retain their configured effect. Existing affected profiles are invalidated once and rebuilt automatically. -- **Predictive demand protection could ignore a sustained overload when consecutive grid readings stayed numerically unchanged**: fresh meter publications now advance the three-sample hard-limit confirmation independently from incremental P/D updates, while timer-only watchdog passes break the confirmation streak and unchanged normal readings still avoid duplicate control calculations. -- **Zendure SolarFlow Mix discharge power could remain capped at 2400 W**: the user-facing `inverse_max_power` control and its reported device value now synchronize and persist the canonical discharge limit. Existing 4000 Mix AC+ and Mix Pro installations whose hardware already reports 4000 W recover automatically after startup, while new control changes still take effect immediately. -- **Combined system power caps could assume a fixed 2500 W per battery**: charge and discharge cap sliders now derive their maxima dynamically from the sum of the configured effective battery limits in each direction, so higher-power batteries are no longer artificially capped. -- **Active Cell Balance could miss Cell Delta when the BMS cut below 3.60 V**: a confirmed charge rejection in the upper cell-voltage window now enters the same 60-second settled measurement used at the configured stop voltage before continuing with adaptive discharge. Rejections below that window remain retry diagnostics and do not contaminate the formal balance history. - -## [1.4.0b4] - 2026-08-21 - -### Fixed - -- **Predictive charging could oscillate between maximum power and 0 W**: ordinary grid-import overshoots now modulate a positive battery-charge power instead of suspending predictive control, while confirmed demand protection still requires three fresh samples before using the 0 W safety stop. Recovery resumes from measured grid headroom rather than restarting at the battery's maximum charge power. -- **Dynamic EV power control could suppress residual solar charging after the EV stopped**: the restart hold now gives the wallbox a short re-yield window, then allows the battery to absorb stable solar surplus while keeping discharge blocked for the full safety hold. Blocker logs now report the actual source instead of mislabeling it as a time-slot restriction. -- **Predictive Time Slot windows could delay charging by five minutes on every entry**: the fixed entry wait was replaced with immediate evaluation when the solar forecast is valid (or not configured), bounded retries during transient forecast outages, and a conservative zero-solar fallback after the retry grace. Additional configured windows no longer inherit an unnecessary delay. - -## [1.4.0b3] - 2026-08-20 - -### Fixed - -- **Toggling Predictive Charging could make every integration entity unavailable and overflow the dashboard websocket**: enabling or disabling the feature no longer reloads the config entry. Predictive diagnostics, mode controls, history capture and the Dynamic Pricing timer now remain registered behind live gates; disabling clears active charging ownership and schedules an immediate normal-control cycle, while enabling Dynamic Pricing rebuilds the remaining-day plan in place. -- **Consumption profile sensors used an invalid `measurement` state class for the `energy` device class**: the expected home consumption forecast now has no state class because it is recalculated, while the live daily capture uses `total_increasing` for its monotonic accumulation and local-midnight reset. -- **Repeated warnings while the grid sensor was temporarily unavailable**: `unknown` and `unavailable` are now treated as expected transient Home Assistant states and logged only once per episode at debug level. Missing sensors and genuinely malformed values still produce one warning per episode, while automatic control remains safely paused until a numeric reading returns. -- **Temporary remaining-consumption curve could amplify an already high daily total**: live adjustment now reconciles the shaped fallback with the unconsumed daily budget instead of projecting the elapsed excess a second time into the remaining horizon. The existing gradual confidence and 30% bound remain in place, preventing both late-day overcharging and a one-off load spike from erasing plausible future demand. -- **Home Consumption could become `unknown` after a transient telemetry mismatch**: positive balances are no longer rejected solely because they fell below half of the previous reading, and the display sensor holds its last coherent value during an invalid negative/near-zero balance. Daily energy tracking remains strict and excluded-load control ignores held display values. -- **Predictive-charging timeline diagnostics could become unknown after a no-charge evaluation**: Dynamic Pricing now builds and preserves solar-timeline, solar-profile and projected-depletion diagnostics even when the balance decides that no grid charging is needed, so later balance-only reevaluations cannot erase them from the predictive-charging sensor. -- **Top-charge Cell Delta could remain stale when the BMS cut below 3.60 V**: a confirmed BMS cutoff in the taper zone now starts the same 60-second settled measurement used at the top-voltage point, including during weekly full charge, so packs that stop at 3.58 V still publish a formal reading. -- **Re-saving time slots or excluded devices could reset their runtime controls** (#307): the options flow now preserves a time slot's Enabled state and an excluded device's Enabled and Exclusion % values while editing the same record, including when an activity-only device gains a power sensor. Replacing a record still starts from safe defaults instead of inheriting hidden settings. Thanks to @syphernl. - -## [1.4.0b2] - 2026-08-18 - -### Added - -- **Direct-PV solar temporal profile capture**: bounded persistence, quality diagnostics and DST-aware solar-window learning are now available. Existing whole-day solar forecast sensors are converted once to a remaining budget; the learned curve is selected automatically once mature and the sinusoidal curve remains the fallback while it learns or becomes invalid. Real-Time Price remains reactive. -- **Shared dated solar timeline for Dynamic Pricing and Time Slot**: provider, learned-profile, sinusoidal and zero fallbacks distribute the forecast across time while keeping the forecast total authoritative and applying the safety margin only once. -- **Anker Solarbank identification and aggregate PV telemetry** (#297): the driver now resolves official product codes from `device_model` or the embedded serial number (for example, `DN7M`/`DPM4` → Solarbank 4 E5000 Pro). The official PV registers are exposed as aggregate `solar_power` plus lifetime PV generation, and are included in daily solar totals, system sensors, efficiency calculations and the dashboard. The published map has no individual MPPT channels, so the integration does not create synthetic `MPPT1..4` entities. [`anker.py`](custom_components/omnibattery/drivers/anker.py), [`consumption_tracker.py`](custom_components/omnibattery/tracking/consumption_tracker.py), [`marstek-panel.js`](custom_components/omnibattery/frontend/marstek-panel.js). -- **Zendure SolarFlow 4000 Mix AC+ support** (#298): local HTTP model detection now recognizes `solarFlow4000MixAC+` / `ZDA2502`, applies a symmetric 4000 W charge/discharge envelope, exposes the correct inverter-limit range, omits DC MPPT entities, and promotes legacy 2400 AC+ entries without raising their saved user ceilings. -- **Zendure SolarFlow 4000 Mix Pro support**: local HTTP model detection recognizes `solarFlow4000MixPro` / `ZDA2501`, applies the 4000 W bidirectional AC envelope, exposes the dual DC MPPT capability and promotes legacy generic Zendure entries to the correct profile. - -### Changed - -- **Predictive price and time-slot charging now schedules energy before it is needed**: the planner simulates the remaining day in 15-minute intervals. Dynamic Pricing reserves the cheapest eligible capacity before each projected battery-depletion deadline, while fixed Time Slot mode distributes distinct kWh quotas across the configured windows instead of letting the first window consume the complete daily target. Impossible plans expose an explicit shortfall. Guaranteed Minimum SOC can create a pre-solar reserve even on a solar-positive day, with its existing hysteresis, explicit price ceiling and physical safety limits preserved. Real-Time Price remains deliberately reactive because it has no future price calendar from which to select feasible slots. - -### Fixed - -- **Weekly full charge could leave a Zendure at 100% SOC after its scheduled day**: an unfinished run now restores each battery's saved cutoff when the charge day ends, persists that restore information across Home Assistant restarts, recognizes Zendure's SOC-only BMS cutoff, and retries rejected hardware writes. -- **DHCP MAC tracking could follow a bridge or powerline adapter instead of the battery** (PR#295): a matching MAC is no longer enough to adopt a new lease. Before changing the stored endpoint, the integration probes the candidate with the battery's driver and stored credentials, including authenticated Sessy support, safely closes and restores the active connection around the probe, and leaves the existing address untouched when the candidate is silent. Thanks to @wilsto -- **Predictive charging could trigger an economic discharge after stopping for a demand spike** (#299): active predictive slots now retain control ownership, command idle, wait for fresh settled telemetry, and only then use Peak Shaving for the measured excess over the configured limit. Peak Shaving and contracted-power emergency protection bypass price-only discharge blocks but retain battery, SOC and ownership safety limits; recovery requires hysteresis instead of a single near-limit reading. -- **Daily Home Consumption energy could include a false 0 W sample during a battery/grid telemetry transition**: the physical accumulator now validates its own grid+battery+solar balance instead of clamping a transient negative value to zero, without applying predictive external-load adjustments to the dashboard total. -- **Predictive charging could start and stop repeatedly with three-phase protection enabled**: phase-safety replay now preserves predictive's negative charge sign in `previous_power`, preventing active charging from being misread as discharge and clamped to `0 W`. - -## [1.4.0b1] - 2026-08-17 - -### Changed - -- **Maximum contracted power increased to 20,000 W**: the `max_contracted_power` setting in the initial setup and options flow now accepts values from 1,000 to 20,000 W (previously capped at 15,000 W). -- **28-day quarter-hour household consumption profile**: the integration now learns 96 local intervals from adjusted home demand across the previous 28 days, with trapezoidal integration, coverage thresholds, DST-aware binning, weighted weekday/day-type/global fallbacks and background Recorder backfill. Predictive charging, Solar Charge Delay and Dynamic Pricing use the profile only when it is mature and otherwise report the legacy daily fallback. The isolated Store, diagnostics sensors, dashboard card and source/coverage metadata make both the learning state and the live current-day capture visible without changing the legacy history contract. -- **Temporary daily fallback curve**: before the 15-minute profile is mature, legacy daily consumption is distributed with a normalized household shape that keeps overnight demand low, includes breakfast and daytime activity, and makes dinner the main peak. Dynamic Pricing remaining-day forecasts adapt gradually to today's measured consumption with a bounded correction, while DST-aware normalization keeps the daily kWh total unchanged. -- **Remaining-today solar forecasts are now supported without breaking existing setups**: a new recommended sensor field accepts the provider's production still expected today and replaces/removes the legacy whole-day forecast when saved. Intraday predictive charging, Solar Charge Delay and Smart Pre-discharge use matching remaining-energy horizons without subtracting measured production or applying the solar curve twice; untouched legacy `today` sensors continue through the existing approximation during the transition. A Home Assistant Repair guides legacy-only entries and clears automatically after migration. Forecast values are now normalized from Wh to kWh and their active source is exposed in diagnostics. -- **Predictive-charging diagnostics were simplified**: seven redundant attributes were removed from `binary_sensor.omnibattery_predictive_charging_active`. Use `household_consumption_full_day_kwh`, `daily_consumption_history`, `usable_energy_kwh`, `consumption_accumulator_source`, `solar_forecast_source`, `sensor.omnibattery_daily_solar_energy` and `planned_grid_charge_kwh` as their canonical replacements. - -### Fixed - -- **Hoymiles MS-A2 MQTT onboarding could fail with older firmware payloads**: numeric telemetry and power-envelope fields are now accepted both as JSON numbers and as numeric strings, keeping firmware such as `01.05.06` compatible without changing the handling of newer firmware such as `01.06.03`; invalid and non-finite values remain rejected. -- **Dashboard charts could use the browser timezone instead of Home Assistant's timezone** (#266): history queries, day boundaries, hover clocks, weekday labels and the current profile hour now follow the user's Home Assistant timezone preference. Calendar-day calculations also account for daylight-saving days instead of assuming every day has exactly 24 hours. -- **Hoymiles keepalive could delay Home Assistant startup** (#283): the MQTT command watchdog now runs as a named background task, so its long-lived refresh loop is excluded from the bootstrap wait while still being cancelled cleanly when the integration unloads. -- **Predictive charging notifications could mix daily and remaining forecast labels**: Predictive Charging, Dynamic Pricing and pre-slot confirmations now identify whether solar and household consumption cover today or only the period until midnight, recognize learned-profile and temporary-curve scopes, and show the forecast basis on a separate line instead of presenting remaining consumption as a seven-day average. -- **Predictive charging windows could truncate daily household consumption**: the legacy daily history, same-day accumulator, Recorder backfill and quarter-hour forecast consumers now keep the full 24-hour household demand, including weekends and predictive grid-charging windows. Battery charging itself is still excluded naturally because negative battery AC power cancels the matching grid import. Existing windowed totals are rebuilt from Recorder instead of mixing partial-day and full-day data. -- **Grid at Min SOC could remain at zero with a disabled manual timeslot**: merely retaining a disabled or charge-only timeslot in configuration no longer suppresses the accumulator. Only enabled slots that allow discharge restrict it to their active window; with no such slot, unmet grid demand is counted all day. -- **Predictive charging could report a missing solar forecast during a transient midnight update**: when the configured remaining-today sensor was temporarily unavailable, the one-shot slot evaluation now waits and retries instead of consuming the evaluation with a misleading safe-mode notification. -- **Dynamic Pricing could ignore a configured remaining-today sensor**: horizon selection now resolves the persisted forecast configuration before choosing the daily fallback, so a valid remaining sensor is used by the 00:05 evaluation even if the controller's runtime cache is stale. -- **Dynamic Pricing remaining-consumption forecast could count today's home energy twice**: Manual recalculations now subtract the full-day home energy already accumulated since midnight and plan only the consumption remaining until midnight. Notifications use the explicit remaining value, with a safe adjusted full-day fallback during startup and diagnostics identifying the source used. -- **Home Consumption could briefly fall to 0 W during battery charging**: the derived sensor now holds its last valid positive estimate for up to 15 seconds when independently polled grid, solar and battery telemetry produces a transient negative balance, exposes the raw balance and estimate quality for diagnostics, and becomes unknown rather than publishing a false zero when the invalid balance persists. -- **The live household-consumption profile could record an implausibly small positive balance during battery charging**: the profile and legacy daily accumulator now share the same transient-balance guard, registerless battery power follows the aggregate sensor's sign convention, and raw profile days captured before the guard are rebuilt from Recorder after the upgrade. -- **The live profile capture could briefly fall to 0 kWh during a Home Assistant restart or Recorder backfill**: empty profile days and baseline-only samples now report no state until covered intervals exist, so charts no longer interpret an uninitialized capture as zero energy. -- **The slow grid-sensor warning stayed after the sensor recovered** (PR#280): a transient stall of three intervals raised the Repairs warning, and nothing could clear it until the integration or Home Assistant restarted, so the warning kept describing a fast meter as slow. Twenty consecutive fast intervals now clear it during the same run, and a sensor that slows down again raises it once more. Clearing deliberately needs a longer streak than raising, so a sensor hovering around the ten-second threshold does not churn the repair. A warning left over from an earlier run now also needs that longer streak, so it disappears about twenty publications into the new run instead of three. Thanks to @syphernl -- **PD control quality no longer reports `sluggish` while the controller is not allowed to act** (PR#286): the per-cycle restriction check keyed on the commanded power, which a previously blocked cycle had already zeroed, so a blocked charge demand was evaluated as a discharge question, passed, and fed the full grid error into the quality metric. With charge delay active and a solar surplus the sensor read `sluggish` on the most aggressive profile while the batteries sat idle. The demand direction now comes from the grid-error sign, and `blocked` is reported only when the command has no headroom left in the direction it is already running (reducing discharge while charging is blocked remains a fair tuning verdict). Blocked cycles are skipped by the metric and the sensor reports a new `blocked` state. A verdict whose metric has not advanced for over 5 minutes is reported as `collecting_data` instead of as a live verdict; `metric_age_s` was added to the attributes. The `blocked` and `battery_limited` flags are stamped when set and expire after a minute, so a cycle that never reaches the PD tail (weekly full charge or predictive charging owning the cycle, max SOC handling, manual mode) neither erases a verdict that is still true nor latches a stale one for the whole session. Thanks to @syphernl -- **Force Mode and the charge/discharge setpoints silently did nothing under automatic control**: on register-backed drivers (Marstek, ESPHome) these entities write the device registers directly, but the control loop re-asserts them every cycle, so the write was reverted before it took effect and the battery appeared unresponsive. Writing them while the controller owns the battery now raises an error naming Manual Mode instead of accepting the change. Battery configuration registers (SOC cutoffs, power caps) stay writable, and both Manual Mode switches (system-wide and per battery) release the guard. Thanks to @syphernl +## [1.4.0] - 2026-09-04 + +### Added + +- **Huawei SUN2000 + LUNA2000 driver**: adds native Modbus telemetry and optional service/direct-write control for Huawei hybrid inverters with LUNA2000 storage. Cascade reconfiguration verifies the selected inverter serial before pairing telemetry and commands. Thanks to @sphings79 for the contribution. +- **Off-grid meter mode**: an optional second W/kW power sensor, with its own inverted-sign setting, exposes a Home Assistant switch and dashboard control that change the active source used by PD control and derived consumption/grid statistics. The switch is software-only and never enables or changes a battery's physical off-grid/EPS port. +- **Daily Operation timeline in the dashboard**: the Summary tab shows a 24-hour, 15-minute chart with observed and projected battery actions, solar and household energy, total SOC, scheduled grid charging, price context, Charge Delay milestones and simultaneous-action overlays. The read-only projection follows the executable plan without modifying it, completes the interval around the current-time marker coherently and hides clocks, legends and causes for disabled features. +- **Vacation Mode dashboard switch**: a new common system control pauses household-consumption learning and the legacy daily average while physical metering and battery control continue normally. Predictive features use a constant baseline learned from 01:00–05:00, taking the median of up to the last three nights with at least three hours of coverage; vacation intervals remain excluded from Recorder backfill after the mode is disabled. +- **Learned direct-PV solar timeline**: bounded persistence, per-day admission diagnostics and DST-aware solar-window learning build a dated 15-minute profile for Dynamic Pricing and Time Slot. Provider, learned-profile, sinusoidal and zero fallbacks preserve the authoritative forecast total, apply the safety margin once and recover automatically from changed measurement scales or solar-capacity regimes. +- **Anker Solarbank identification and aggregate PV telemetry** (#297, #340): official product codes are resolved from `device_model` or the embedded serial number. Solarbank 4 E5000 Pro (`DN7M`/`DPM4`) exposes aggregate PV power and lifetime generation for system energy and dashboard calculations; Max AC and XE AC models keep the configured external solar sensor as their only source, and no synthetic per-MPPT entities are created. +- **Zendure SolarFlow 4000 Mix support** (#298): local HTTP detection recognizes Mix AC+ (`solarFlow4000MixAC+` / `ZDA2502`) and Mix Pro (`solarFlow4000MixPro` / `ZDA2501`), applies their 4000 W bidirectional envelope, restores that limit on existing installations and exposes DC MPPT only on Mix Pro. Legacy generic entries are promoted without raising saved user ceilings. +- **Per-pack SOC on Venus A/D** (#350): six diagnostic sensors, disabled by default and polled every 30 seconds, expose each coupled pack's SOC and learn the populated slots at start-up. +- **Venus A/D panel topology setting** (#361): battery setup now distinguishes available MPPT hardware from panels actually connected. Panel-equipped installations retain the AC+MPPT flow model; unused inputs keep AC power as the battery-flow source and do not contribute MPPT capability to solar totals. Existing entries migrate with panels enabled to preserve prior behaviour. + +### Changed + +- **Maximum contracted power increased to 20,000 W**: `max_contracted_power` in setup and options now accepts 1,000–20,000 W instead of stopping at 15,000 W. +- **Household-consumption learning now uses a 28-day quarter-hour profile**: 96 local intervals are learned from adjusted demand with trapezoidal integration, coverage checks, DST-aware binning and weighted weekday/day-type/global fallbacks. Learning and Recorder recovery follow the configured sources independently of the Predictive Charging switch; learned days survive options changes, source changes break only sample continuity, and Home Assistant timezone changes re-bin instead of erasing history. The profile becomes usable from matching weekday/weekend samples, excludes configured external loads by covered energy and exposes Store, source, coverage and live-capture diagnostics. Vacation Mode deliberately pauses new learning. +- **Immature consumption profiles use a bounded temporary daily curve**: the legacy daily total is distributed across a normalized household shape and reconciled with energy already consumed, adapting gradually without duplicating elapsed demand or letting one load spike erase plausible remaining consumption. +- **Remaining-today solar forecasts are now first-class inputs**: a recommended sensor field accepts production still expected before midnight and replaces the legacy whole-day field when saved. Predictive Charging, Solar Charge Delay and Smart Pre-discharge share one normalized Wh/kWh horizon, preserve dated provider periods and use a bounded midnight grace for unavailable or premature zero readings; untouched whole-day sensors remain compatible through a single conversion, and a Home Assistant Repair guides migration. +- **Predictive price and Time Slot charging schedules energy before it is needed**: a 15-minute remaining-day simulation reserves the cheapest eligible capacity before each projected depletion deadline and distributes separate kWh quotas across fixed windows. Impossible plans expose their shortfall, Guaranteed Minimum SOC can reserve energy before solar production, and no-charge evaluations retain their timeline and depletion diagnostics. Real-Time Price remains reactive because it has no future price calendar. +- **Predictive-charging diagnostics were simplified**: seven redundant attributes were removed from `binary_sensor.omnibattery_predictive_charging_active`; their canonical replacements are `household_consumption_full_day_kwh`, `daily_consumption_history`, `usable_energy_kwh`, `consumption_accumulator_source`, `solar_forecast_source`, `sensor.omnibattery_daily_solar_energy` and `planned_grid_charge_kwh`. +- **Multi-battery control now supports up to ten batteries**: configuration includes per-battery Time Slot fields and the dashboard grid remains usable for larger fleets. +- **Recorder recovery now uses bounded local-day queries under one entry-owned coordinator**: household, legacy-consumption, solar-profile and daily-counter work is serialized, observes 23/24/25-hour days, checkpoints completed coverage and bins detached high-cadence data outside the event loop. Historical reconstruction no longer blocks setup or overlaps old and reloaded runtimes. +- **Runtime polling, timeline updates and persistence have one lifecycle owner**: `DataUpdateCoordinator` is the sole battery poller; cached Daily Operation snapshots are push-only and coalesced, Store writes are debounced with immediate structural flushes, and control, pricing, notification and persistence tasks are cancelled before hardware teardown. Bounded counters expose this work in diagnostics. + +### Fixed + +- **Older Hoymiles MS-A2 firmware could fail MQTT onboarding or delay Home Assistant startup** (#283): numeric strings are accepted for telemetry and power envelopes while invalid/non-finite values remain rejected, and the command keepalive runs as a named lifecycle task outside the bootstrap wait. +- **Dashboard charts and controls could use the wrong clock or interrupt touch input** (#266): history boundaries, labels and hover times now use Home Assistant's timezone, DST calendar days and 12/24-hour preference; mobile sliders retain the submitted thumb position until Home Assistant confirms it. +- **Predictive notifications could mix full-day and remaining forecasts**: notifications identify each solar and consumption horizon, distinguish learned and temporary profiles and report the grid energy that determines the target. +- **Predictive consumption accounting could truncate or duplicate daily demand**: history and Recorder backfill retain all 24 hours, including predictive windows, while manual and intraday plans subtract consumption already accumulated and forecast only the remainder. +- **Grid at Min SOC could remain at zero because of a disabled manual Time Slot**: only enabled slots that allow discharge restrict the accumulator; otherwise unmet grid demand is counted all day. +- **Grid-sensor health could remain noisy or stale** (PR#280): transient `unknown`/`unavailable` states are logged once per episode at debug level while control pauses safely, and twenty consecutive fast publications clear an obsolete slow-sensor Repair without churning near the threshold. Thanks to @syphernl. +- **PD control quality could report `sluggish` while control was blocked** (PR#286): demand direction follows the grid-error sign, blocked or battery-limited states expire, and a verdict that has not advanced for five minutes returns to `collecting_data`. Thanks to @syphernl. +- **Force Mode and charge/discharge setpoints could silently do nothing under automatic control**: register-backed writes now raise an error directing the user to Manual Mode instead of being immediately overwritten. Configuration registers remain writable, and system or per-battery Manual Mode releases the guard. Thanks to @syphernl. +- **Weekly Full Charge could leave a Zendure at 100% SOC after its scheduled day**: unfinished runs restore the saved cutoff at day end, persist the restoration across Home Assistant restarts, recognize Zendure's SOC-only BMS cutoff and retry rejected writes. +- **DHCP MAC tracking could follow a bridge or powerline adapter instead of the battery** (PR#295): a candidate lease is now probed with the battery driver and stored credentials before changing the endpoint, including authenticated Sessy support; silent, ambiguous or conflicting candidates leave the existing address untouched. Thanks to @wilsto. +- **Predictive demand protection could lose control around an overload** (#299): active slots retain ownership, settle at idle and apply Peak Shaving only to measured excess after a confirmed demand spike. Fresh meter publications advance hard-limit confirmation even when values repeat, three-phase replay preserves the charging sign, and hysteresis prevents an economic discharge or repeated start/stop during recovery. +- **Home Consumption could publish false zero or unknown values during telemetry transitions**: the display holds its last coherent positive estimate briefly, the physical accumulator validates its own grid+battery+solar balance, and daily tracking and excluded-load control remain strict instead of consuming the held display value. +- **Toggling Predictive Charging could reload the integration and make every entity unavailable**: predictive diagnostics, controls, history capture and pricing timers now remain registered behind live gates; disabling releases charging ownership and enabling Dynamic Pricing rebuilds the remaining-day plan in place. +- **Cell Delta could remain stale or be contaminated by a low-voltage retry**: a confirmed BMS cutoff or charge rejection in the upper cell-voltage window starts the same 60-second settled measurement used at the configured stop, including Weekly Full Charge and Active Cell Balance, while lower-voltage rejection diagnostics stay out of formal balance history. +- **Re-saving Time Slots or excluded devices could reset runtime controls** (#307): editing the same record preserves Enabled and Exclusion % values, including when an activity-only device gains a power sensor; replacement records still start from safe defaults. Thanks to @syphernl. +- **Predictive charging could oscillate between maximum power and 0 W**: ordinary import overshoots now modulate a positive charge command, confirmed demand protection retains its three-sample stop, and recovery starts from measured grid headroom. +- **Dynamic EV control could suppress residual solar charging after the wallbox stopped**: the restart hold gives the wallbox a short re-yield window, then lets the battery absorb stable surplus while discharge remains blocked for the full safety hold. +- **Time Slot evaluation could start five minutes late or leave diagnostics unknown**: valid/no-forecast entries evaluate immediately, transient forecast failures use bounded retries and later configured windows no longer inherit the delay; the post-00:05 daily balance also runs after the final window without creating a false active calendar. +- **Combined system power caps could assume 2,500 W per battery**: charge and discharge sliders now derive their maxima independently from the sum of configured effective limits, so higher-power batteries are not artificially capped. +- **Reloading could discard pending Charge Delay or Weekly Full Charge state**: entry-owned tasks are cancelled first, then both managers synchronously flush their final coherent latch and restore data before teardown. +- **Venus D firmware below EMS 149 could reject 2,500 W limits**: setup, runtime capabilities and number entities now use a 2,200 W ceiling below EMS 149, retain 2,500 W from EMS 149 onward and start conservatively at 2,200 W when firmware is unknown. +- **Solar Charge Delay could unlock permanently at midnight or retain stale setpoint status**: the manager uses the shared normalized remaining-solar input, preserves dated periods and gives premature scalar zeroes a bounded retry grace. Obsolete balance and unlock details are cleared while charging to the configured SOC setpoint, and legacy whole-day forecasts are converted only once. +- **Dynamic Pricing could lose deadline-aware daily or evening planning**: solar boundaries preserve timezone awareness when tomorrow's prices are unavailable, Charge Delay reuses parsed prices within each quarter, and evening deficit scheduling reads the asynchronous consumption tracker without crashing. +- **Selecting an Omnibattery solar output as the external production sensor could create runaway totals**: setup and options reject aggregate, per-battery, renamed and legacy Omnibattery solar entities, while existing circular configurations are ignored at runtime. +- **Weekly Full Charge could override a predictive grid-charge target**: while Predictive Charging owns the session, its per-battery target remains authoritative and grid import stops at the planned quota; the weekly cycle can still permit later solar charging toward 100%. +- **Backup-port discharge could be omitted from energy totals and round-trip efficiency** (#321): the shared energy layer integrates supplemental discharge through semantic driver hooks. Venus E v3 counts fresh off-grid output only in Backup Mode and excludes grid passthrough, updating total/daily discharge, calculated cycles and RTE without brand logic in shared calculations. +- **Dynamic Power Control could compete with an active Solar Surplus wallbox** (#371): residual monitoring re-yields when the wallbox loses at least 200 W of solar margin, while equal wallbox and solar reductions still leave residual battery charging available. +- **Predictive charging could not go below 800 W on Marstek v2/v3**: the driver no longer treats the lowest configurable power limit as a minimum operating command, allowing predictive charging and thermal derating below 800 W without changing Peak Shaving. +- **Venus A/D multi-pack charging could stop when an early pack filled** (#350): charge limits, full detection and BMS-cutoff arming now follow the least-full populated pack, discharge follows the fullest, and a sustained accepted 200 W retry rearms detection after each pack handover. Batteries without per-pack SOC keep their previous behavior. +- **The Venus A/D register documentation misidentified pack SOC and cell-voltage layout**: `registers.md` now distinguishes aggregate from pack 1 SOC and documents the 100-register stride between packs. ## [1.3.0] - 2026-08-14 diff --git a/README.md b/README.md index 420e5de1..8073f501 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,13 @@ - Marstek Venus E and C (v2 and v3), Venus D and Venus A via Modbus TCP - Zendure SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus and 800 (Local API) - Anker SOLIX Solarbank Max AC and Solarbank 4 E5000 Pro via Modbus TCP (thanks @wouterbouvy!) +- Huawei SUN2000 + LUNA2000 via Modbus TCP (validated on SUN2000-8K-MAP0; thanks @sphings79!) - Sessy Home Battery (Looking for testers!!!) - Hoymiles MS-A2 and HiBattery MQTT models ## Key Features -- **Mix and match different battery brands**: Marstek, Zendure, Anker Solix, Sessy, Hoymiles MQTT and more to come! +- **Mix and match different battery brands**: Marstek, Zendure, Anker Solix, Huawei, Sessy, Hoymiles MQTT and more to come! - **Zero Export/Import PD Controller**: Keeps grid exchange near zero using a Proportional-Derivative algorithm. - **Integrated dashboard**: All the controls and adjustments from a single place. Graphs and power flow diagram included! - **One-Click PD Profiles + Quality Sensor**: Pick a tuning profile (Very smooth → Very aggressive) instead of tuning gains by hand; a control-quality sensor reports whether the result is stable, oscillating or sluggish. @@ -54,8 +55,8 @@ Full documentation (configuration, features, entities, troubleshooting) is avail | Requirement | Details | |---|---| -| Battery | Marstek Venus E v2/v3, Venus A, Venus D, Zendure SolarFlow 4000 Mix Pro / 4000 Mix AC+, 2400 AC+, 2400 AC Pro, Anker SOLIX Solarbank Max AC / 4 E5000 Pro | -| Modbus bridge | Elfin-EW11 or compatible RS485-to-TCP converter. Venus E v3, Venus A and Venus D can also be connected via Ethernet with native Modbus TCP support. Anker Solarbank Max AC and 4 E5000 Pro use native Modbus TCP (enable in the Anker app under Third-Party Control; only one Modbus client at a time). | +| Battery | Marstek Venus E v2/v3, Venus A, Venus D, Zendure SolarFlow 4000 Mix Pro / 4000 Mix AC+, 2400 AC+, 2400 AC Pro, Anker SOLIX Solarbank Max AC / 4 E5000 Pro, Huawei SUN2000 + LUNA2000 | +| Modbus bridge | Elfin-EW11 or compatible RS485-to-TCP converter. Venus E v3, Venus A and Venus D can also be connected via Ethernet with native Modbus TCP support. Anker Solarbank Max AC and 4 E5000 Pro use native Modbus TCP (enable in the Anker app under Third-Party Control; only one Modbus client at a time). Huawei uses the SUN2000 Modbus TCP endpoint; use a shared Modbus proxy when another client is connected. | | Wireless connection | Required for Zendure SolarFlow 4000 Mix AC+, 2400 AC+ and 2400 AC Pro | | Grid sensor | HA sensor measuring total grid consumption (e.g. Shelly EM3, Neurio, smart meter) | | Network | Battery reachable by IP from Home Assistant | diff --git a/custom_components/omnibattery/__init__.py b/custom_components/omnibattery/__init__.py index 15e56d05..4ebd314c 100644 --- a/custom_components/omnibattery/__init__.py +++ b/custom_components/omnibattery/__init__.py @@ -229,6 +229,7 @@ GRID_CHARGE_NOT_NEEDED, GRID_CHARGE_SCHEDULED, ) +from .control.pack_soc import soc_vs_ceiling, soc_vs_floor from .control.weekly_full_charge import WeeklyFullChargeManager from .control.max_soc_charge import MaxSocChargeManager from .control.temperature_limit import TemperatureChargeLimitManager @@ -1345,6 +1346,18 @@ def _daily_operation_delay_active(self) -> bool: "waiting for solar", } + def _daily_operation_setpoint_enabled(self) -> bool: + """Return whether the Charge Delay SOC setpoint is actually in effect. + + The setpoint is a sub-feature of Charge Delay: the controller ignores it + when the delay is off (``is_charge_delayed`` returns early), so the + timeline must not paint the setpoint marker either. + """ + return bool( + getattr(self, "charge_delay_enabled", False) + and getattr(self, "_delay_soc_setpoint_enabled", False) + ) + def _daily_operation_hourly_balance_context(self, action_mask: int) -> int: """Classify a measured action driven by the hourly net-balance offset. @@ -1614,7 +1627,9 @@ def _daily_operation_runtime_decision( ChargeDischargeController._daily_operation_weekly_delay_bypass(self) ) delay_active = self._daily_operation_delay_active() - setpoint_enabled = bool(getattr(self, "_delay_soc_setpoint_enabled", False)) + setpoint_enabled = ( + ChargeDischargeController._daily_operation_setpoint_enabled(self) + ) setpoint_reached = bool(getattr(self, "_delay_setpoint_reached", False)) setpoint_active = ( setpoint_enabled @@ -1911,7 +1926,9 @@ def projection_datetime(value: datetime) -> datetime: system_charge_power_w = configured_charge if configured_discharge > 0.0: system_discharge_power_w = configured_discharge - setpoint_enabled = bool(getattr(self, "_delay_soc_setpoint_enabled", False)) + setpoint_enabled = ( + ChargeDischargeController._daily_operation_setpoint_enabled(self) + ) setpoint_reached = bool(getattr(self, "_delay_setpoint_reached", False)) target_soc_pct = None solar_t_end = None @@ -2104,7 +2121,9 @@ def _refresh_daily_operation_timeline( } setpoint = { - "enabled": bool(getattr(self, "_delay_soc_setpoint_enabled", False)), + "enabled": ( + ChargeDischargeController._daily_operation_setpoint_enabled(self) + ), "target_soc": self._daily_operation_float( getattr(self, "_delay_soc_setpoint", None), 0.0 ), @@ -3382,6 +3401,10 @@ def _refresh_battery_charge_limit_blocks(self) -> None: coordinator, weekly_100_unlocked, ) + # A coupled-pack battery is full when its *least* full pack reaches + # the ceiling; its aggregate gets there while the last pack is still + # filling (issue #350). Equals current_soc without pack telemetry. + ceiling_soc = soc_vs_ceiling(coordinator, current_soc) should_charge_to_bms = getattr(self, "_should_charge_to_bms_cutoff", None) if should_charge_to_bms is not None and should_charge_to_bms( @@ -3433,9 +3456,11 @@ def _refresh_battery_charge_limit_blocks(self) -> None: coordinator._hysteresis_active = False coordinator._hysteresis_base_soc = None - if current_soc >= coordinator.max_soc or bms_cutoff or taper_at_top_voltage: + if ceiling_soc >= coordinator.max_soc or bms_cutoff or taper_at_top_voltage: coordinator._hysteresis_active = True if coordinator._hysteresis_base_soc is None: + # Base stays the aggregate: it answers "how far must the + # whole battery fall before recharging", not "is it full". coordinator._hysteresis_base_soc = current_soc hysteresis_base = ( @@ -3450,7 +3475,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None: coordinator._hysteresis_base_soc = None if coordinator._hysteresis_active: - if current_soc >= effective_max_soc or bms_cutoff: + if ceiling_soc >= effective_max_soc or bms_cutoff: self.set_charge_block( "max_soc", "max_soc", @@ -3484,7 +3509,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None: self.remove_charge_block("charge_hysteresis", coordinator=coordinator) - if current_soc >= effective_max_soc or bms_cutoff: + if ceiling_soc >= effective_max_soc or bms_cutoff: self.set_charge_block( "max_soc", "max_soc", @@ -3495,6 +3520,7 @@ def _refresh_battery_charge_limit_blocks(self) -> None: "effective_max_soc": effective_max_soc, "source": max_soc_source, "bms_cutoff": bms_cutoff, + **({"min_pack_soc": ceiling_soc} if ceiling_soc != current_soc else {}), }, coordinator=coordinator, ) @@ -3521,7 +3547,11 @@ def _refresh_battery_discharge_limit_blocks(self) -> None: current_soc = coordinator.data.get("battery_soc", 0) effective_min_soc, min_soc_source = self._effective_discharge_min_soc(coordinator) - if current_soc <= effective_min_soc: + # A coupled-pack battery is empty when its *fullest* pack reaches the + # floor, not when its aggregate does (issue #350). Falls back to the + # aggregate on every battery that publishes no per-pack telemetry. + floor_soc = soc_vs_floor(coordinator, current_soc) + if floor_soc <= effective_min_soc: self.set_discharge_block( "min_soc", "min_soc", @@ -3531,6 +3561,7 @@ def _refresh_battery_discharge_limit_blocks(self) -> None: "min_soc": coordinator.min_soc, "effective_min_soc": effective_min_soc, "source": min_soc_source, + **({"max_pack_soc": floor_soc} if floor_soc != current_soc else {}), }, coordinator=coordinator, ) @@ -3804,6 +3835,10 @@ def _get_available_batteries( should_charge_to_bms is not None and should_charge_to_bms(coordinator, effective_max_soc) ) + # Judged on the least full pack for a coupled-pack battery, so a + # finished first pack cannot end the charge (issue #350). + # Identical to current_soc without pack telemetry. + ceiling_soc = soc_vs_ceiling(coordinator, current_soc) # Update hysteresis state if enabled if coordinator.enable_charge_hysteresis: @@ -3857,7 +3892,7 @@ def _get_available_batteries( coordinator._hysteresis_active = False coordinator._hysteresis_base_soc = None - if current_soc >= coordinator.max_soc or _taper_at_top: + if ceiling_soc >= coordinator.max_soc or _taper_at_top: coordinator._hysteresis_active = True # Capture the actual SOC that triggered hysteresis (may be 100% after full charge) if coordinator._hysteresis_base_soc is None: @@ -3919,7 +3954,7 @@ def _get_available_batteries( continue # Only charge if below effective max SOC - if current_soc < effective_max_soc or charge_to_bms_cutoff: + if ceiling_soc < effective_max_soc or charge_to_bms_cutoff: available_batteries.append(coordinator) else: # discharging # MIN-SOC RE-ENTRY HYSTERESIS: after emptying to min_soc the @@ -3927,11 +3962,15 @@ def _get_available_batteries( # the battery for a sliver of discharge — relay ping-pong and # micro-cycles at the worst SOC region. Latch the exclusion at # min_soc; release only after a real recovery margin. - if current_soc <= coordinator.min_soc: + # Judged on the fullest pack for a coupled-pack battery: the + # aggregate hits the floor while a pack still has charge to give + # (issue #350). Identical to current_soc without pack telemetry. + floor_soc = soc_vs_floor(coordinator, current_soc) + if floor_soc <= coordinator.min_soc: coordinator._discharge_min_soc_latched = True - elif current_soc >= coordinator.min_soc + DISCHARGE_MIN_SOC_REENTRY_MARGIN: + elif floor_soc >= coordinator.min_soc + DISCHARGE_MIN_SOC_REENTRY_MARGIN: coordinator._discharge_min_soc_latched = False - if current_soc > coordinator.min_soc: + if floor_soc > coordinator.min_soc: if getattr(coordinator, "_discharge_min_soc_latched", False): _LOGGER.debug( "%s: Skipping discharge - min-SOC re-entry hysteresis " diff --git a/custom_components/omnibattery/const/registers_va.py b/custom_components/omnibattery/const/registers_va.py index 2c4a131a..5c03e20d 100644 --- a/custom_components/omnibattery/const/registers_va.py +++ b/custom_components/omnibattery/const/registers_va.py @@ -455,3 +455,32 @@ "scan_interval": "high", }, ] + +# --- per-pack SOC (issue #350) ---------------------------------------------- +# Venus A/D couple several battery packs and fill them in sequence, so the +# aggregate SOC at 32104 can read 100 % while a later pack is still empty. Each +# pack publishes its own SOC on a stride-100 layout — 34000 + 100·(n−1), SOC at +# offset +2 — in deci-percent (the aggregate is whole percent). The six +# addresses are 100 registers apart, so no block read applies (REGISTER_BLOCKS +# never pads gaps, issue #361) and each costs its own frame: polled at "low" +# because a pack SOC moves ~0.1 %/min in absorption and a handover takes +# minutes. Slots this installation does not have are dropped by the driver's +# start-up probe (MarstekModbusDriver._learn_packs). +PACK_SOC_KEYS = tuple(f"battery_soc_pack_{n}" for n in range(1, 7)) + +SENSOR_DEFINITIONS_VA.extend( + { + "name": f"Battery SOC Pack {n}", + "register": 34002 + 100 * (n - 1), + "scale": 0.1, + "unit": "%", + "device_class": "battery", + "state_class": "measurement", + "key": key, + "enabled_by_default": False, + "data_type": "uint16", + "precision": 1, + "scan_interval": "low", + } + for n, key in enumerate(PACK_SOC_KEYS, start=1) +) diff --git a/custom_components/omnibattery/control/charge_delay.py b/custom_components/omnibattery/control/charge_delay.py index 2a782447..3b594650 100644 --- a/custom_components/omnibattery/control/charge_delay.py +++ b/custom_components/omnibattery/control/charge_delay.py @@ -666,7 +666,7 @@ def _unlock(reason): return _unlock("past_t_end") # --- Calculate energy balance --- - # Energy needed to reach target_soc + # Energy needed to reach target_soc (battery-side / stored kWh) energy_needed_kwh = sum( (target_soc - c.data.get("battery_soc", 100)) / 100.0 * c.data.get("battery_total_energy", 0) for c in automatic_batteries if c.data @@ -675,6 +675,16 @@ def _unlock(reason): if energy_needed_kwh <= 0: return _unlock("batteries_full") + # AC-side equivalent of the same deficit: every comparison against a + # solar/production-side figure (remaining_solar_kwh, net_solar_for_battery) + # must use this, not the raw battery-side energy_needed_kwh, or charge + # losses get silently ignored and the forecast looks more sufficient + # than it really is (by roughly 1/CHARGE_EFFICIENCY, i.e. ~5-15% + # optimistic at typical efficiencies). + energy_needed_ac_kwh = ( + energy_needed_kwh / CHARGE_EFFICIENCY if CHARGE_EFFICIENCY > 0 else energy_needed_kwh + ) + # Charge time estimate max_charge_power_kw = ctrl._effective_system_capacity( automatic_batteries, @@ -735,8 +745,39 @@ def _unlock(reason): # Time backup check safety_margin_h = ctrl._delay_safety_margin_h - time_limit_reached = (now_h + charge_time_h + safety_margin_h) >= t_end - energy_insufficient = net_solar_for_battery < (energy_needed_kwh * DELAY_SAFETY_FACTOR) + deadline_h = t_end - safety_margin_h + # Two independent estimates of the latest safe unlock time: + # nominal_time_backup_unlock_h — assumes charging happens at the + # full hardware rate once unlocked (optimistic; only realistic if + # grid charging kicks in, since solar-following PD rarely reaches + # max_charge_power_kw). + # solar_feasible_unlock_h — the latest unlock such that the + # solar forecast ALONE (sinusoidal curve, no grid) can still + # deliver the BARE target (no cushion — that's already handled by + # energy_insufficient/the cushion-hold branch above) by the + # safety-margin deadline. Reuses the same well-tested + # energy-balance projection as the normal unlock estimate, just + # bounded to the deadline instead of t_end. + # Taking the EARLIER of the two means the delay unlocks in time for + # realistic solar-only charging to finish, instead of only unlocking + # late enough for a best-case full-power (often grid-assisted) finish + # — this closes the gap where grid charging would otherwise show up + # near the deadline because solar-only charging started too late. + nominal_time_backup_unlock_h = t_end - charge_time_h - safety_margin_h + solar_feasible_unlock_h = self._estimate_energy_balance_unlock_h( + forecast_today, energy_needed_ac_kwh, ctrl._solar_t_start, t_end, now_h, + safety_factor=1.0, + forecast_is_remaining=forecast_is_remaining, + consumption_profile=profile_forecast, + horizon_h=deadline_h, + ) + time_backup_unlock_h = ( + min(nominal_time_backup_unlock_h, solar_feasible_unlock_h) + if solar_feasible_unlock_h is not None + else nominal_time_backup_unlock_h + ) + time_limit_reached = now_h >= time_backup_unlock_h + energy_insufficient = net_solar_for_battery < (energy_needed_ac_kwh * DELAY_SAFETY_FACTOR) # Update status with calculation details status["energy_needed_kwh"] = round(energy_needed_kwh, 2) @@ -746,9 +787,8 @@ def _unlock(reason): status["charge_time_h"] = round(charge_time_h, 2) # Estimate unlock time: earliest of time-backup and energy-balance triggers - time_backup_unlock_h = t_end - charge_time_h - safety_margin_h energy_balance_unlock_h = self._estimate_energy_balance_unlock_h( - forecast_today, energy_needed_kwh, ctrl._solar_t_start, t_end, now_h, + forecast_today, energy_needed_ac_kwh, ctrl._solar_t_start, t_end, now_h, forecast_is_remaining=forecast_is_remaining, consumption_profile=profile_forecast, ) @@ -770,11 +810,11 @@ def _unlock(reason): ctrl._delay_last_log_time = current_time _LOGGER.info( "Charge Delay (target=%d%%): Solar remaining=%.1f kWh, Consumption remaining=%.1f kWh, " - "Net for battery=%.1f kWh, Needed=%.1f kWh (×%.1f=%.1f), " + "Net for battery=%.1f kWh, Needed=%.1f kWh AC-equiv (÷%.2f) ×%.1f=%.1f, " "Charge time=%.1fh, Hours to T_end=%.1fh → %s", target_soc, remaining_solar_kwh, remaining_consumption_kwh, - net_solar_for_battery, energy_needed_kwh, - DELAY_SAFETY_FACTOR, energy_needed_kwh * DELAY_SAFETY_FACTOR, + net_solar_for_battery, energy_needed_kwh, CHARGE_EFFICIENCY, + DELAY_SAFETY_FACTOR, energy_needed_ac_kwh * DELAY_SAFETY_FACTOR, charge_time_h, hours_to_t_end, "KEEP DELAY" if not energy_insufficient and not time_limit_reached else "UNLOCK" ) @@ -789,10 +829,10 @@ def _unlock(reason): # bounded by the moment the BARE balance (no safety factor) is projected # to break, so the SOC target stays reachable and the cushion is the only # thing spent. Genuine deficits (net < needed) still unlock immediately. - if net_solar_for_battery >= energy_needed_kwh: + if net_solar_for_battery >= energy_needed_ac_kwh: cushion_edge_h = self._estimate_energy_balance_unlock_h( forecast_today, - energy_needed_kwh, + energy_needed_ac_kwh, ctrl._solar_t_start, t_end, now_h, @@ -810,21 +850,23 @@ def _unlock(reason): _LOGGER.info( "Charge Delay: Cushion-only shortfall (net=%.1f >= needed=%.1f, " "factored=%.1f) - holding for cheaper hour %.2fh (edge %.2fh)", - net_solar_for_battery, energy_needed_kwh, - energy_needed_kwh * DELAY_SAFETY_FACTOR, release_h, edge_h, + net_solar_for_battery, energy_needed_ac_kwh, + energy_needed_ac_kwh * DELAY_SAFETY_FACTOR, release_h, edge_h, ) return True _LOGGER.info( "Charge Delay: Insufficient solar (net=%.1f < needed=%.1f) - unlocking (reason: energy_balance)", - net_solar_for_battery, energy_needed_kwh * DELAY_SAFETY_FACTOR + net_solar_for_battery, energy_needed_ac_kwh * DELAY_SAFETY_FACTOR ) return _unlock("energy_balance") if time_limit_reached: _LOGGER.info( - "Charge Delay: Time limit (%.2f + %.2f + %.2f = %.2f >= T_end %.2f) - unlocking (reason: time_backup)", - now_h, charge_time_h, safety_margin_h, - now_h + charge_time_h + safety_margin_h, t_end + "Charge Delay: Time limit (now=%.2f >= unlock=%.2f; nominal=%.2f, " + "solar_feasible=%s, deadline=%.2f) - unlocking (reason: time_backup)", + now_h, time_backup_unlock_h, nominal_time_backup_unlock_h, + f"{solar_feasible_unlock_h:.2f}" if solar_feasible_unlock_h is not None else "n/a", + deadline_h, ) return _unlock("time_backup") @@ -1025,6 +1067,7 @@ def _estimate_energy_balance_unlock_h( safety_factor: float = DELAY_SAFETY_FACTOR, forecast_is_remaining: bool = False, consumption_profile=None, + horizon_h: float | None = None, ) -> float | None: """Estimate when the energy balance condition will trigger the delay unlock. @@ -1036,12 +1079,21 @@ def _estimate_energy_balance_unlock_h( last moment the forecast still covers the target with no cushion left, which bounds how long the cushion-only price hold may wait. + ``horizon_h`` optionally shrinks the window solar/consumption are summed + over to ``[t, horizon_h]`` instead of ``[t, t_end]`` — e.g. the safety- + margin deadline rather than sunset — while keeping the sinusoidal + production CURVE shaped by the real ``(t_start, t_end)`` daylight window + (the sun doesn't stop shining at the deadline; only energy arriving + after it no longer helps meet it in time). Passing ``t_end`` itself (the + default) reproduces the original whole-daylight-window behavior exactly. + Returns the estimated hour as float, or None if it cannot be estimated. """ ctrl = self._controller daylight_hours = t_end - t_start if daylight_hours <= 0: return None + horizon = t_end if horizon_h is None else max(t_start, min(horizon_h, t_end)) # Keep this aligned with _should_delay_charge(): avg_consumption is # measured over the full local day, not daylight hours. @@ -1075,7 +1127,7 @@ def profile_consumption_at(t: float) -> float | None: microsecond=0, ) start = midnight + timedelta(hours=max(0.0, t)) - end = midnight + timedelta(hours=max(0.0, t_end)) + end = midnight + timedelta(hours=max(0.0, horizon)) result = ctrl._consumption_tracker.forecast_consumption_between( start, end, fallback="legacy_daily" ) @@ -1086,23 +1138,29 @@ def profile_consumption_at(t: float) -> float | None: return None def net_solar_at(t: float) -> float: - """Net solar available for battery at time t.""" + """Net solar available for battery between t and the horizon.""" progress = max(0.0, min(1.0, (t - t_start) / daylight_hours)) fraction_done = (1.0 - math.cos(math.pi * progress)) / 2.0 + horizon_progress = max(0.0, min(1.0, (horizon - t_start) / daylight_hours)) + fraction_done_horizon = (1.0 - math.cos(math.pi * horizon_progress)) / 2.0 if forecast_is_remaining: # Renormalize the provider's post-now energy over the remaining # daylight curve, never treating it as a full-day forecast. now_progress = max(0.0, min(1.0, (now_h - t_start) / daylight_hours)) remaining_at_now = max(1e-9, 1.0 - (1.0 - math.cos(math.pi * now_progress)) / 2.0) - remaining_solar = forecast_kwh * (1.0 - fraction_done) / remaining_at_now + remaining_solar = ( + forecast_kwh + * max(0.0, fraction_done_horizon - fraction_done) + / remaining_at_now + ) else: - remaining_solar = forecast_kwh * (1.0 - fraction_done) + remaining_solar = forecast_kwh * max(0.0, fraction_done_horizon - fraction_done) profile_consumption = profile_consumption_at(t) if profile_consumption is not None: remaining_consumption = profile_consumption else: remaining_window_hours = ctrl._consumption_tracker.consumption_window_hours_in_range( - t, t_end + t, horizon ) remaining_consumption = ( avg_consumption * (remaining_window_hours / window_hours_per_day) @@ -1115,12 +1173,12 @@ def net_solar_at(t: float) -> float: if net_solar_at(now_h) < threshold: return now_h - # If still above threshold at t_end, no energy-balance unlock expected - if net_solar_at(t_end) >= threshold: + # If still above threshold at the horizon, no energy-balance unlock expected + if net_solar_at(horizon) >= threshold: return None # Binary search for crossing point - lo, hi = now_h, t_end + lo, hi = now_h, horizon for _ in range(40): # 40 iterations → precision < 1 second mid = (lo + hi) / 2.0 if net_solar_at(mid) >= threshold: diff --git a/custom_components/omnibattery/control/pack_soc.py b/custom_components/omnibattery/control/pack_soc.py new file mode 100644 index 00000000..45315acf --- /dev/null +++ b/custom_components/omnibattery/control/pack_soc.py @@ -0,0 +1,50 @@ +"""Which SOC a coupled-pack battery is judged by (issue #350). + +A Venus A/D couples several battery packs and fills them **in sequence**, so its +aggregate SOC is not the number either end of the charge should be decided on: +it can read the ceiling while the last pack is still half empty. Per-pack SOC +(``battery_soc_pack_1..6``) makes the real state visible, and the verdicts +become asymmetric: + +* **full** when the *least* full pack reaches the ceiling — ``min(pack_soc)``; +* **empty** when the *fullest* pack reaches the floor — ``max(pack_soc)``. + +Both helpers fall back to the aggregate when the battery publishes no per-pack +telemetry, which is every model except Venus A/D and any Venus A/D slot that did +not answer the driver's probe. So a battery with one pack, or none exposed, +behaves exactly as it did before. +""" +from __future__ import annotations + +# Pack telemetry keys are read by prefix rather than from a fixed list: the key +# shape is the contract, so a second brand exposing per-pack SOC the same way is +# picked up without touching the control layer. +_PACK_SOC_PREFIX = "battery_soc_pack_" + + +def pack_socs(coordinator) -> list[float]: + """Return this battery's per-pack SOCs, empty when it publishes none. + + Values are bounded on read: the Venus A/D pack addresses come from a + third-party register map, not from Marstek, and an out-of-range reading here + would move a charge or discharge limit. + """ + return [ + value + for key, value in (getattr(coordinator, "data", None) or {}).items() + if key.startswith(_PACK_SOC_PREFIX) + and isinstance(value, (int, float)) + and 0 <= value <= 100 + ] + + +def soc_vs_ceiling(coordinator, aggregate): + """SOC that decides whether to keep *charging*: the least full pack.""" + packs = pack_socs(coordinator) + return min(packs) if packs else aggregate + + +def soc_vs_floor(coordinator, aggregate): + """SOC that decides whether to keep *discharging*: the fullest pack.""" + packs = pack_socs(coordinator) + return max(packs) if packs else aggregate diff --git a/custom_components/omnibattery/control/temperature_limit.py b/custom_components/omnibattery/control/temperature_limit.py index 5317ec2e..92cd8c98 100644 --- a/custom_components/omnibattery/control/temperature_limit.py +++ b/custom_components/omnibattery/control/temperature_limit.py @@ -53,8 +53,7 @@ def _derate_factor(self, temp: float) -> float: def _min_power(coordinator, is_charging: bool) -> int: """The battery's minimum reliable operating power (0 when it has none). - Read from the driver capabilities, which derive it from the - max_charge/discharge_power register floor (v2/v3 = 800 W, vA/vD/Zendure = 0). + Read from the driver capabilities (0 for drivers with no such floor). """ caps = getattr(coordinator, "capabilities", None) attr = "min_charge_power_w" if is_charging else "min_discharge_power_w" @@ -75,7 +74,7 @@ def _apply(self, coordinator, limit: int, is_charging: bool) -> int: return limit derated = min(limit, int(round(limit * factor))) # Hard floor: never command a non-zero power below the battery's minimum - # reliable operating power (v2/v3 = 800 W; vA/vD/Zendure = 0). Never raise + # reliable operating power (0 when the driver declares none). Never raise # above a limit that was already below that floor. return max(derated, min(self._min_power(coordinator, is_charging), limit)) diff --git a/custom_components/omnibattery/control/weekly_full_charge.py b/custom_components/omnibattery/control/weekly_full_charge.py index c1217020..4cdb3426 100644 --- a/custom_components/omnibattery/control/weekly_full_charge.py +++ b/custom_components/omnibattery/control/weekly_full_charge.py @@ -21,6 +21,7 @@ from homeassistant.helpers.storage import Store +from .pack_soc import pack_socs, soc_vs_ceiling from ..const import ( DOMAIN, NORMAL_BALANCE_BMS_CUTOFF_VERSIONS, @@ -144,6 +145,17 @@ def tick_bms_cutoff(self) -> None: in_taper_zone = vmax is not None and float(vmax) >= NORMAL_BALANCE_TAPER_CELL_VOLTAGE except (TypeError, ValueError): pass + # On a coupled-pack battery the taper clause alone is a false + # positive machine (issue #350): a pack that finished hours ago holds + # the top cell high while later packs are still filling, so a lull in + # acceptance during a hand-over gets counted as a cutoff at 89% SOC. + # Require the *least* full pack to be at the top as well. Only where + # packs actually report: the taper clause exists precisely to fire + # below 99% aggregate SOC (coulomb drift), so falling back to the + # aggregate here would disable it for every other battery. + packs = pack_socs(c) + if in_taper_zone and packs and min(packs) < 99: + in_taper_zone = False if soc >= 99 or in_taper_zone: power = c.data.get("battery_power", None) inv_state = c.data.get("inverter_state", None) @@ -219,7 +231,10 @@ def is_battery_full(self, coordinator: Any) -> bool: return False if not coordinator.data: return False - soc = coordinator.data.get("battery_soc", 0) + # The least full pack decides on a coupled-pack battery: its aggregate + # reaches 100% while a later pack is still filling (issue #350). Returns + # the aggregate unchanged for every battery without per-pack telemetry. + soc = soc_vs_ceiling(coordinator, coordinator.data.get("battery_soc", 0)) top_charge_manager = getattr(self._controller, "_max_soc_mgr", None) prepare_retry = getattr(top_charge_manager, "prepare_bms_cutoff_retry", None) if prepare_retry is not None: diff --git a/custom_components/omnibattery/drivers/base.py b/custom_components/omnibattery/drivers/base.py index 8f0b0b58..64920e3c 100644 --- a/custom_components/omnibattery/drivers/base.py +++ b/custom_components/omnibattery/drivers/base.py @@ -129,9 +129,9 @@ class DriverCapabilities: engage_grace_s: Optional[float] = None # Minimum reliable operating power (watts, per unit) below which the hardware - # will not sustain a non-zero charge/discharge. Marstek v2/v3 report 800 W (the - # max_charge/discharge_power register floor); vA/vD/Zendure have no such floor - # and report 0. The thermal derate clamps its non-zero output up to this value + # will not sustain a non-zero charge/discharge. Every Marstek model reports 0 + # (its setpoint registers accept any power); Anker reports a real floor. + # The thermal derate clamps its non-zero output up to this value # so it never dribbles an unreliable sub-minimum command. Defaults to 0 (no floor). min_charge_power_w: int = 0 min_discharge_power_w: int = 0 diff --git a/custom_components/omnibattery/drivers/marstek.py b/custom_components/omnibattery/drivers/marstek.py index c7dd9ec3..a5c5eb9b 100644 --- a/custom_components/omnibattery/drivers/marstek.py +++ b/custom_components/omnibattery/drivers/marstek.py @@ -26,6 +26,7 @@ from ..const import ( MESSAGE_WAIT_MS, + PACK_SOC_KEYS, READ_TIMEOUT_S, REGISTER_MAP, max_power_for_battery_version, @@ -45,6 +46,15 @@ # hardware SOC cut-off registers, packet correction. _V3_FAMILY = ("v3", "vA", "vD") +# Venus A/D pack-SOC discovery (issue #350). How many packs a Venus A/D has is +# not readable anywhere, so the populated slots are learned from which of them +# answers. Give each slot this many poll cycles ("low" = 30 s) before deciding it +# is absent, so one transient read failure cannot hide a real pack. An empty slot +# may answer 0 rather than failing, so a 0 only disqualifies a slot when the +# aggregate SOC says the battery holds real charge. +_PACK_PROBE_CYCLES = 3 +_EMPTY_SLOT_AGGREGATE_SOC = 5 + # Marstek force_mode register values. _FORCE_NONE = 0 _FORCE_CHARGE = 1 @@ -255,6 +265,23 @@ def __init__( # and lock per group without seeing the register layout. self._read_groups = self._build_read_groups() + # Which pack slots this Venus A/D actually has, learned from the ones + # that answer (see _learn_pack). Every indexed slot is polled while + # probing; once the set is frozen the absent ones leave the schedule for + # good, because re-probing them forever would cost a read timeout per + # poll on the single-slot v3-family MCU. Keyed per slot: a slot drops out + # of this dict when it is confirmed or when its attempts run out, and an + # empty dict means the set is final. + self._pack_soc_capable = any(k in self._telemetry_index for k in PACK_SOC_KEYS) + self._packs: set[str] = set() + self._pack_probes_left: dict[str, int] = { + key: _PACK_PROBE_CYCLES for key in PACK_SOC_KEYS if key in self._telemetry_index + } + # Last aggregate SOC seen, so the probe can tell an empty slot reading 0 + # from a real pack that is genuinely flat. It arrives in a different read + # group, hence the cache. + self._last_aggregate_soc: Optional[float] = None + # The apply-path clamp (apply_setpoint) must be this model's *hardware* # ceiling — the writable power register's max — not the user's per-battery # limit. The user limit is enforced live via coordinator.max_charge_power, @@ -271,10 +298,13 @@ def __init__( hw_charge_ceiling = min(hw_charge_ceiling, firmware_ceiling) hw_discharge_ceiling = min(hw_discharge_ceiling, firmware_ceiling) self._set_power_definition_ceiling(firmware_ceiling) - # Register floor (v2/v3 = 800 W, vA/vD = 0): the minimum reliable operating - # power the thermal derate must not command below. 0 when absent. - hw_charge_floor = int(number_defs.get("max_charge_power", {}).get("min", 0)) - hw_discharge_floor = int(number_defs.get("max_discharge_power", {}).get("min", 0)) + # Minimum operating power = the *setpoint* register floor (0 on every + # Marstek model). NOT max_charge_power's min (800 W on v2/v3): that is + # only the lowest ceiling the user may configure, not a power the + # hardware refuses to deliver, and reading it as a floor pinned + # predictive charging at 800 W of grid import. + hw_charge_floor = int(number_defs.get("set_charge_power", {}).get("min", 0)) + hw_discharge_floor = int(number_defs.get("set_discharge_power", {}).get("min", 0)) # Static capabilities, derived from the register map + the seeded entity # definitions so the control layer never branches on the version string. @@ -358,7 +388,14 @@ def model_label(self) -> str: @property def sensor_definitions(self) -> list[dict]: - return self._definitions["sensor"] + if not self._pack_soc_capable or self._pack_probes_left: + # Still probing: nothing is hidden, since "has not answered yet" is + # not "is not there". + return self._definitions["sensor"] + return [ + d for d in self._definitions["sensor"] + if d["key"] not in PACK_SOC_KEYS or d["key"] in self._packs + ] @property def number_definitions(self) -> list[dict]: @@ -457,6 +494,51 @@ def _build_read_groups(self) -> list[ReadGroup]: groups.append(ReadGroup(scan_interval=defn.get("scan_interval"), keys=(key,))) return groups + @property + def _active_pack_keys(self) -> frozenset[str]: + """Pack-SOC keys worth polling: every slot while probing, the found ones after.""" + if not self._pack_soc_capable: + return frozenset() + if self._pack_probes_left: + return frozenset(k for k in PACK_SOC_KEYS if k in self._telemetry_index) + return frozenset(self._packs) + + def _learn_pack(self, key: str, raw: object) -> None: + """Fold one pack-SOC read into the populated-slot set (issue #350). + + A slot the hardware does not have either fails to answer — its key is + missing from the snapshot — or reads a flat 0. Neither is conclusive on + its own: a read fails transiently too, and a real pack can sit at 0 %. So + a 0 disqualifies a slot only while the aggregate SOC says the battery + holds meaningful charge, and each slot gets _PACK_PROBE_CYCLES attempts + before it is written off. + """ + confirmed = raw is not None and ( + raw != 0 + or ( + self._last_aggregate_soc is not None + and self._last_aggregate_soc <= _EMPTY_SLOT_AGGREGATE_SOC + ) + ) + if confirmed: + self._packs.add(key) + self._pack_probes_left.pop(key, None) + else: + self._pack_probes_left[key] -= 1 + if self._pack_probes_left[key] <= 0: + del self._pack_probes_left[key] + if self._pack_probes_left: + return + absent = set(PACK_SOC_KEYS) - self._packs + self._read_groups = [ + g for g in self._read_groups if absent.isdisjoint(g.keys) + ] + _LOGGER.info( + "[%s] Pack SOC probe finished: %d pack(s) present (%s)", + getattr(self._client, "host", "?"), + len(self._packs), ", ".join(sorted(self._packs)) or "none", + ) + @property def read_groups(self) -> list[ReadGroup]: return self._read_groups @@ -512,6 +594,12 @@ async def read_telemetry(self, keys: Optional[list[str]] = None) -> TelemetrySna ) if value is not None: snapshot[key] = value + + if "battery_soc" in snapshot: + self._last_aggregate_soc = snapshot["battery_soc"] + for key in wanted: + if key in self._pack_probes_left: + self._learn_pack(key, snapshot.get(key)) return snapshot # --- control (write) ---------------------------------------------------- @@ -667,12 +755,14 @@ def net_power_from_data(self, data: dict): @property def control_dependency_keys(self) -> frozenset: + # The pack SOCs ship disabled by default but the discharge floor reads + # them (issue #350), so they must keep polling regardless. return frozenset({ "set_charge_power", "set_discharge_power", "max_charge_power", "max_discharge_power", "force_mode", "charging_cutoff_capacity", "discharging_cutoff_capacity", - }) + }) | self._active_pack_keys async def apply_config( self, diff --git a/custom_components/omnibattery/frontend/marstek-panel.js b/custom_components/omnibattery/frontend/marstek-panel.js index 4e541eb5..686e567a 100644 --- a/custom_components/omnibattery/frontend/marstek-panel.js +++ b/custom_components/omnibattery/frontend/marstek-panel.js @@ -3382,13 +3382,19 @@ class MarstekVenusPanel extends HTMLElement { const index = snapshot.currentIndex; const value = this._dailyOperationNumber(plotted[index]); const seconds = this._dailyOperationValueAt(coverage, index); - // The live capture contains energy accumulated so far. Extrapolate only - // the open cell so it remains comparable with the completed 15-minute - // cells and does not create a false drop at the now marker. Below a - // minute of coverage the factor exceeds 15x and would amplify noise, so - // the point is omitted instead of plotting a near-zero partial. - if (value != null && seconds != null && seconds < 900) { - plotted[index] = seconds >= 60 ? value * 900 / seconds : null; + const previous = this._dailyOperationValueAt(plotted, index - 1); + // The live capture contains energy accumulated so far, so the open cell + // has to be completed before it is comparable with the closed 15-minute + // cells. Scaling it (value * 900 / seconds) divides by a small number + // early in the quarter: an energy sensor that reports every 30 s leaves + // ~0 kWh over the first minute, which collapsed the point and made the + // forecast hand-off read as a spike -- and dropping it below a minute + // punched a 30-minute hole straddling the now marker. Complete it from + // the previous closed quarter instead: energy already seen plus that + // quarter's share of the time still to come. Continuous at both ends + // (all of `previous` at 0 s, all of `value` at 900 s), no small divisor. + if (seconds != null && seconds < 900 && (value != null || previous != null)) { + plotted[index] = (value ?? 0) + (previous ?? 0) * (900 - seconds) / 900; } return plotted; } diff --git a/custom_components/omnibattery/manifest.json b/custom_components/omnibattery/manifest.json index 8cfb764f..19222f05 100644 --- a/custom_components/omnibattery/manifest.json +++ b/custom_components/omnibattery/manifest.json @@ -28,7 +28,7 @@ "pymodbus>=3.8.0", "pyserial>=3.5" ], - "version": "1.4.0b8", + "version": "1.4.0", "zeroconf": [ "_modbus._tcp.local." ] diff --git a/custom_components/omnibattery/strings.json b/custom_components/omnibattery/strings.json index beadc565..f78636c5 100644 --- a/custom_components/omnibattery/strings.json +++ b/custom_components/omnibattery/strings.json @@ -1642,6 +1642,24 @@ "battery_soc": { "name": "Battery SOC" }, + "battery_soc_pack_1": { + "name": "Battery SOC Pack 1" + }, + "battery_soc_pack_2": { + "name": "Battery SOC Pack 2" + }, + "battery_soc_pack_3": { + "name": "Battery SOC Pack 3" + }, + "battery_soc_pack_4": { + "name": "Battery SOC Pack 4" + }, + "battery_soc_pack_5": { + "name": "Battery SOC Pack 5" + }, + "battery_soc_pack_6": { + "name": "Battery SOC Pack 6" + }, "battery_total_energy": { "name": "Battery Total Energy" }, diff --git a/custom_components/omnibattery/tracking/consumption_profile.py b/custom_components/omnibattery/tracking/consumption_profile.py index c9656a65..068f3a53 100644 --- a/custom_components/omnibattery/tracking/consumption_profile.py +++ b/custom_components/omnibattery/tracking/consumption_profile.py @@ -318,6 +318,69 @@ def _local_segments( return segments +def _named_timezone(name: str) -> Any | None: + """Resolve a stored IANA name, or ``None`` when it no longer exists.""" + try: + return dt_util.get_time_zone(name) or ZoneInfo(name) + except Exception: # noqa: BLE001 - HA may expose a custom tz provider + return None + + +def rebin_days_to_timezone( + days: dict[date, ProfileDay], + old_tz: Any, + new_tz: Any, +) -> dict[date, ProfileDay]: + """Re-express local-clock bins in another timezone instead of dropping them. + + Every current IANA offset is a whole number of quarter-hours apart, so each + stored bin lands on exactly one target bin; the proportional split below is + only the safety net for a pair that does not line up. + """ + rebinned: dict[date, ProfileDay] = {} + partial: set[date] = set() + for day in days.values(): + for index in range(INTERVAL_COUNT): + coverage = day.coverage_s[index] + energy = day.energy_kwh[index] + if coverage <= 0.0 or not math.isfinite(coverage) or not math.isfinite(energy): + continue + wall = datetime.combine( + day.local_date, + time( + index // INTERVALS_PER_HOUR, + (index % INTERVALS_PER_HOUR) * INTERVAL_MINUTES, + ), + ) + # ponytail: fold=0 only. An autumn repeated hour was already merged + # into one bin by capture, so its second pass moves an hour early + # once a year; splitting it back apart is not recoverable anyway. + start_ts = wall.replace(tzinfo=old_tz).timestamp() + segments = _local_segments( + _datetime_from_timestamp(start_ts, new_tz), + _datetime_from_timestamp(start_ts + INTERVAL_SECONDS, new_tz), + ) + span = math.fsum(end - start for start, end, _ in segments) + if span <= 0.0: + continue + for segment_start, segment_end, midpoint in segments: + share = (segment_end - segment_start) / span + local_date = midpoint.date() + target = rebinned.setdefault(local_date, ProfileDay(local_date)) + target_index = _interval_index(midpoint.timetz().replace(tzinfo=None)) + target.energy_kwh[target_index] += energy * share + target.coverage_s[target_index] += coverage * share + if not day.complete: + partial.add(local_date) + for local_date, day in rebinned.items(): + # The two edge days now hold only part of their hours. Leaving them + # incomplete is what makes Recorder backfill re-fetch them. + day.complete = local_date not in partial and all( + value > 0.0 for value in day.coverage_s + ) + return rebinned + + def _interval_index(local_time: time) -> int: """Return the quarter-hour index for a local time.""" return min(INTERVAL_COUNT - 1, (local_time.hour * 60 + local_time.minute) // INTERVAL_MINUTES) @@ -660,22 +723,34 @@ def _day_has_training_data(self, day: ProfileDay) -> bool: ) def configuration_fingerprint(self) -> str: - """Hash consumption sources and load adjustments, excluding solar forecast.""" + """Hash consumption sources and load adjustments, excluding solar forecast. + + The hash no longer gates the raw days — learned capture is never + discarded for a configuration change — it only tells the update + listener that the sources moved, so a Recorder backfill can fill what + is missing. Devices are hashed by what their fields *mean*, not by + which keys the config happens to carry, so the options flow rewriting + its own defaults does not read as a source change. + """ data = getattr(self._config_entry, "data", {}) or {} devices = [] for device in data.get("excluded_devices", []) or []: if not isinstance(device, dict): continue + exclusion_pct = device.get("exclusion_pct") devices.append( { - key: device.get(key) - for key in ( - "enabled", - "power_sensor", - "included_in_consumption", - "exclusion_pct", - "ev_charger_no_telemetry", - ) + "enabled": bool(device.get("enabled", True)), + "power_sensor": device.get("power_sensor") or None, + "included_in_consumption": bool( + device.get("included_in_consumption", True) + ), + "exclusion_pct": float( + 100 if exclusion_pct is None else exclusion_pct + ), + "ev_charger_no_telemetry": bool( + device.get("ev_charger_no_telemetry", False) + ), } ) devices.sort( @@ -766,17 +841,28 @@ async def async_load(self) -> bool: current_timezone = getattr( getattr(self._hass, "config", None), "time_zone", None ) - if ( - stored_fingerprint - and stored_fingerprint != expected_fingerprint - ) or (stored_timezone and stored_timezone != current_timezone): - self._days = {} - self._invalidated = True - self._active_fingerprint = expected_fingerprint - self._last_error = "profile invalidated after source or timezone change" - self._loaded = True - _LOGGER.info("Consumption profile: invalidated after configuration change") - return False + # No configuration change discards raw days. A source or load change + # keeps them as captured; a timezone move re-bins them, because the 96 + # slots are local wall-clock and would otherwise describe other hours. + previous_tz: Any = None + if stored_timezone and stored_timezone != current_timezone: + previous_tz = _named_timezone(stored_timezone) + if previous_tz is None: + self._days = {} + self._invalidated = True + self._active_fingerprint = expected_fingerprint + self._last_error = "profile invalidated after unknown timezone change" + self._loaded = True + _LOGGER.warning( + "Consumption profile: discarded raw days; stored timezone %s " + "is unknown", + stored_timezone, + ) + return False + if stored_fingerprint and stored_fingerprint != expected_fingerprint: + _LOGGER.info( + "Consumption profile: sources changed; keeping the learned days" + ) if data.get("capture_version") != PROFILE_CAPTURE_VERSION: self._days = {} @@ -803,6 +889,14 @@ async def async_load(self) -> bool: if previous is None or sum(parsed.coverage_s) > sum(previous.coverage_s): loaded[parsed.local_date] = parsed + if previous_tz is not None: + loaded = rebin_days_to_timezone(loaded, previous_tz, self._timezone()) + _LOGGER.info( + "Consumption profile: re-binned %d days from %s to %s", + len(loaded), + stored_timezone, + current_timezone, + ) self._days = loaded self._prune() self._active_fingerprint = expected_fingerprint @@ -814,20 +908,24 @@ async def async_load(self) -> bool: return bool(self._days) def invalidate_if_configuration_changed(self) -> bool: - """Clear incompatible raw data after an options/source update.""" + """Report a source change so the caller can backfill; keep the days. + + The learned profile survives every integration setting. Only the open + sample is dropped, so the first reading taken under the new sources is + a fresh baseline instead of a trapezoid spanning both derivations. + """ current = self.configuration_fingerprint() if current == self._active_fingerprint: return False + # An in-flight backfill still holds the previous device list; stopping + # it lets the caller start one that reads the new configuration. self.cancel_backfill() - self._days = {} self._active_fingerprint = current - self._invalidated = True - self._last_error = "profile invalidated after source or load-adjustment change" self._last_sample_time = None self._last_sample_monotonic = None self._last_power_kw = None self.request_save() - _LOGGER.info("Consumption profile: invalidated after source/load configuration change") + _LOGGER.info("Consumption profile: sources changed; keeping the learned days") return True def _store_payload(self) -> dict[str, Any]: @@ -1262,9 +1360,26 @@ def forecast_for_date( for index, count in enumerate(weekday_counts) if index in requested_indices ) + # A same-weekday pair is the ideal training signal, but demanding it is + # what kept a perfectly usable week of data on the generic legacy curve + # for a fortnight — and made the learned curve vanish on whichever + # weekday happened to hold a single sample. Day-type coverage is an + # accepted substitute: the per-interval blend below already falls back + # to ``day_type_mean`` when no same-weekday sample exists, so the gate + # was rejecting a forecast the maths could already produce. + day_type_sample_intervals = sum( + count >= 2 + for index, count in enumerate(day_type_counts) + if index in requested_indices + ) + sample_threshold = math.ceil(requested_count * 0.75) + enough_samples = ( + weekday_sample_intervals >= sample_threshold + or day_type_sample_intervals >= sample_threshold + ) newest_age = (today - newest).days if newest is not None else None mature = ( - weekday_sample_intervals >= math.ceil(requested_count * 0.75) + enough_samples and total_days >= 7 and coverage_ratio >= 0.80 and newest_age is not None @@ -1277,7 +1392,7 @@ def forecast_for_date( fallback_reason = "no_profile_data" elif total_days < 7: fallback_reason = "insufficient_days" - elif weekday_sample_intervals < math.ceil(requested_count * 0.75): + elif not enough_samples: fallback_reason = "insufficient_weekday_samples" elif coverage_ratio < 0.80: fallback_reason = "insufficient_coverage" diff --git a/custom_components/omnibattery/tracking/consumption_tracker.py b/custom_components/omnibattery/tracking/consumption_tracker.py index 31b56d40..61c82ac0 100644 --- a/custom_components/omnibattery/tracking/consumption_tracker.py +++ b/custom_components/omnibattery/tracking/consumption_tracker.py @@ -1455,24 +1455,21 @@ async def backfill_home_from_history( async def startup_backfill_consumption(self) -> None: """Queue startup history work and return without waiting for Recorder.""" - ctrl = self._controller - - learning_enabled = bool( - ctrl.predictive_charging_enabled or ctrl.charge_delay_enabled - ) - if not learning_enabled and not self._legacy_accumulator_rebuild_pending: - return + # Learning is deliberately not gated on the runtime feature switches. + # Whoever calls this has already decided the feature is configured for + # this entry; a user who turns predictive charging off for a while must + # not come back to an unlearned profile and a seven-day history of + # sentinels. Entries that never configured it never reach here, so the + # Recorder cost still falls only on installations that want it. # Submission order is the queue order: consumption profile, legacy # seven-day compatibility history, and direct-PV profile. No profile # query can overlap another profile or legacy query for this entry. - if learning_enabled: - self.start_consumption_profile_backfill() + self.start_consumption_profile_backfill() self._legacy_backfill_task = self._backfill_coordinator.submit( "legacy_consumption", self._async_backfill_legacy_history ) - if learning_enabled: - self.start_solar_profile_backfill() + self.start_solar_profile_backfill() await asyncio.sleep(0) @@ -1635,14 +1632,15 @@ async def capture_daily_consumption(self, now=None) -> None: into the 7-day history before it resets at midnight, so predictive charging always has historical data. + The accumulator is integrated on every control cycle regardless of the + predictive-charging switch, so the snapshot is free and is taken even + while the feature is off: turning it on must not start from sentinels. + Args: now: Timestamp from scheduler (unused, for compatibility) """ ctrl = self._controller - if not ctrl.predictive_charging_enabled: - return - today = date.today() day_start = datetime.combine(today, time.min, tzinfo=dt_util.now().tzinfo) if self._period_intersects(day_start, day_start + timedelta(days=1)): diff --git a/custom_components/omnibattery/tracking/solar_profile.py b/custom_components/omnibattery/tracking/solar_profile.py index d51e679f..3f73a0ca 100644 --- a/custom_components/omnibattery/tracking/solar_profile.py +++ b/custom_components/omnibattery/tracking/solar_profile.py @@ -965,6 +965,26 @@ def _classify_day(self, day: SolarProfileDay) -> None: day.quality_flags[plateau_index] |= int(SolarQualityFlag.CLIPPING) self._detect_capacity_regime(day.local_date) + @staticmethod + def _recent_totals(days: Sequence[SolarProfileDay]) -> list[float]: + """Reference totals for the relative-energy gates: newest days only. + + Weighing a day against every retained day makes the reference drift + with the season and, worse, lets days recorded under an incompatible + scale veto every correct day that follows them: the relative gate + rejects the new days, so ``_day_quality`` never marks them clear, so + ``_detect_capacity_regime`` -- the machinery that exists to retire the + old scale -- never sees the regime it is supposed to notice. Bounding + the reference to the most recent days breaks that deadlock and lets a + changed scale age out on its own. + """ + ordered = sorted( + (day for day in days if day.daylight_seconds > 0.0), + key=lambda day: day.local_date, + reverse=True, + ) + return [day.total_energy_kwh for day in ordered[:SOLAR_MIN_RECENT_DAYS]] + @staticmethod def _robust_day_peak(day: SolarProfileDay) -> float | None: """Return a peak estimate resistant to isolated sensor spikes.""" @@ -997,7 +1017,7 @@ def _detect_capacity_regime(self, reference_date: date | None = None) -> bool: minimum_days = SOLAR_CAPACITY_RECENT_CLEAR_DAYS + SOLAR_CAPACITY_BASELINE_CLEAR_DAYS if len(complete) < minimum_days: return False - recent_totals = [day.total_energy_kwh for day in complete if day.daylight_seconds > 0.0] + recent_totals = self._recent_totals(complete) clear: list[tuple[date, float]] = [] for day in sorted(complete, key=lambda item: item.local_date): valid, quality, _reason = _day_quality(day, recent_totals) @@ -1022,11 +1042,25 @@ def _detect_capacity_regime(self, reference_date: date | None = None) -> bool: return False new_generation = self._generation + 1 - transition_dates = {local_date for local_date, _peak in recent} + # The confirming days are the newest ones, so the regime boundary sits + # at the *oldest* end of the trailing run that already matches the new + # peak. Carrying that whole run over keeps a usable shape instead of + # blanking the profile until seven fresh days accumulate, and only the + # boundary day is marked as a transition -- it is the one that may + # straddle the change, while the days after it describe the new regime. + carried = list(recent) + for entry in reversed(clear[:baseline_end]): + if abs(entry[1] / recent_peak - 1.0) > SOLAR_CAPACITY_SHIFT_THRESHOLD: + break + carried.insert(0, entry) + carried_dates = {local_date for local_date, _peak in carried} + boundary_date = carried[0][0] for day in self._days.values(): - if day.local_date not in transition_dates or day.generation != self._generation: + if day.local_date not in carried_dates or day.generation != self._generation: continue day.generation = new_generation + if day.local_date != boundary_date: + continue for index, coverage in enumerate(day.coverage_s): if coverage > 0.0: day.quality_flags[index] |= int(SolarQualityFlag.CONFIGURATION_TRANSITION) @@ -1098,7 +1132,7 @@ def _eligible_days( target_daylight_hours: float | None = None, ) -> list[tuple[SolarProfileDay, float, float]]: complete = [day for day in self._days.values() if day.complete and day.generation == self._generation] - recent_totals = [day.total_energy_kwh for day in complete if day.daylight_seconds > 0] + recent_totals = self._recent_totals(complete) result: list[tuple[SolarProfileDay, float, float]] = [] for day in complete: age_days = (target_date - day.local_date).days @@ -1486,6 +1520,47 @@ def start_backfill( "solar_profile", self.async_backfill_from_recorder ) + def _day_reports(self) -> list[dict[str, Any]]: + """Per-day admission verdict so a rejected day names its own reason. + + The aggregate counters say how many days were admitted but never why + the rest were dropped, which leaves a silently rotting profile with no + way to tell a coverage hole from an invalid solar window. + """ + complete = [ + day + for day in self._days.values() + if day.complete and day.generation == self._generation + ] + recent_totals = self._recent_totals(complete) + reports: list[dict[str, Any]] = [] + for local_date in sorted(self._days, reverse=True): + day = self._days[local_date] + valid, quality, reason = _day_quality(day, recent_totals) + _energy, progress_coverage, _flags = _progress_day(day) + daylight = day.daylight_seconds + reports.append( + { + "date": local_date.isoformat(), + "valid": valid, + "reason": reason, + "complete": day.complete, + "generation": day.generation, + "total_kwh": round(day.total_energy_kwh, 3), + "daylight_h": round(day.daylight_hours, 2), + "solar_start": day.solar_start.isoformat() if day.solar_start else None, + "solar_end": day.solar_end.isoformat() if day.solar_end else None, + "coverage_ratio": ( + round(math.fsum(progress_coverage) / daylight, 3) + if daylight > 0.0 + else 0.0 + ), + "forecast_reference_kwh": day.forecast_reference_kwh, + "quality": round(quality, 3), + } + ) + return reports + def diagnostics(self, target_date: date | None = None) -> dict[str, Any]: target_date = target_date or self._today() snapshot = self.learn_shape(target_date) @@ -1528,6 +1603,7 @@ def diagnostics(self, target_date: date | None = None) -> dict[str, Any]: "backfill_blocks": self._backfill_blocks, "backfill_duration_last_s": round(self._backfill_last_duration_s, 3), "shape_progress_24": summary[:24], + "days": self._day_reports(), } diff --git a/custom_components/omnibattery/translations/ca.json b/custom_components/omnibattery/translations/ca.json index cb1080c9..4bb19e82 100644 --- a/custom_components/omnibattery/translations/ca.json +++ b/custom_components/omnibattery/translations/ca.json @@ -1638,6 +1638,24 @@ "battery_soc": { "name": "SOC de la Bateria" }, + "battery_soc_pack_1": { + "name": "SOC Bateria Paquet 1" + }, + "battery_soc_pack_2": { + "name": "SOC Bateria Paquet 2" + }, + "battery_soc_pack_3": { + "name": "SOC Bateria Paquet 3" + }, + "battery_soc_pack_4": { + "name": "SOC Bateria Paquet 4" + }, + "battery_soc_pack_5": { + "name": "SOC Bateria Paquet 5" + }, + "battery_soc_pack_6": { + "name": "SOC Bateria Paquet 6" + }, "battery_total_energy": { "name": "Energia Total de la Bateria" }, diff --git a/custom_components/omnibattery/translations/de.json b/custom_components/omnibattery/translations/de.json index 05fa8f2a..b8de45bf 100644 --- a/custom_components/omnibattery/translations/de.json +++ b/custom_components/omnibattery/translations/de.json @@ -1638,6 +1638,24 @@ "battery_soc": { "name": "Batterie-SOC" }, + "battery_soc_pack_1": { + "name": "Batterie-SOC Pack 1" + }, + "battery_soc_pack_2": { + "name": "Batterie-SOC Pack 2" + }, + "battery_soc_pack_3": { + "name": "Batterie-SOC Pack 3" + }, + "battery_soc_pack_4": { + "name": "Batterie-SOC Pack 4" + }, + "battery_soc_pack_5": { + "name": "Batterie-SOC Pack 5" + }, + "battery_soc_pack_6": { + "name": "Batterie-SOC Pack 6" + }, "battery_total_energy": { "name": "Batteriegesamtenergie" }, diff --git a/custom_components/omnibattery/translations/en.json b/custom_components/omnibattery/translations/en.json index a2c0fddb..22718ad3 100644 --- a/custom_components/omnibattery/translations/en.json +++ b/custom_components/omnibattery/translations/en.json @@ -1648,6 +1648,24 @@ "battery_soc": { "name": "Battery SOC" }, + "battery_soc_pack_1": { + "name": "Battery SOC Pack 1" + }, + "battery_soc_pack_2": { + "name": "Battery SOC Pack 2" + }, + "battery_soc_pack_3": { + "name": "Battery SOC Pack 3" + }, + "battery_soc_pack_4": { + "name": "Battery SOC Pack 4" + }, + "battery_soc_pack_5": { + "name": "Battery SOC Pack 5" + }, + "battery_soc_pack_6": { + "name": "Battery SOC Pack 6" + }, "battery_total_energy": { "name": "Battery Total Energy" }, diff --git a/custom_components/omnibattery/translations/es.json b/custom_components/omnibattery/translations/es.json index f01c89f0..50ec040b 100644 --- a/custom_components/omnibattery/translations/es.json +++ b/custom_components/omnibattery/translations/es.json @@ -1638,6 +1638,24 @@ "battery_soc": { "name": "SOC de la Batería" }, + "battery_soc_pack_1": { + "name": "SOC Batería Paquete 1" + }, + "battery_soc_pack_2": { + "name": "SOC Batería Paquete 2" + }, + "battery_soc_pack_3": { + "name": "SOC Batería Paquete 3" + }, + "battery_soc_pack_4": { + "name": "SOC Batería Paquete 4" + }, + "battery_soc_pack_5": { + "name": "SOC Batería Paquete 5" + }, + "battery_soc_pack_6": { + "name": "SOC Batería Paquete 6" + }, "battery_total_energy": { "name": "Energía Total de la Batería" }, diff --git a/custom_components/omnibattery/translations/fr.json b/custom_components/omnibattery/translations/fr.json index 830b1a2a..03adf914 100644 --- a/custom_components/omnibattery/translations/fr.json +++ b/custom_components/omnibattery/translations/fr.json @@ -1651,6 +1651,24 @@ "battery_soc": { "name": "SOC Batterie" }, + "battery_soc_pack_1": { + "name": "SOC Batterie Pack 1" + }, + "battery_soc_pack_2": { + "name": "SOC Batterie Pack 2" + }, + "battery_soc_pack_3": { + "name": "SOC Batterie Pack 3" + }, + "battery_soc_pack_4": { + "name": "SOC Batterie Pack 4" + }, + "battery_soc_pack_5": { + "name": "SOC Batterie Pack 5" + }, + "battery_soc_pack_6": { + "name": "SOC Batterie Pack 6" + }, "battery_total_energy": { "name": "Énergie Totale Batterie" }, diff --git a/custom_components/omnibattery/translations/nl.json b/custom_components/omnibattery/translations/nl.json index e462a970..0be7d579 100644 --- a/custom_components/omnibattery/translations/nl.json +++ b/custom_components/omnibattery/translations/nl.json @@ -1638,6 +1638,24 @@ "battery_soc": { "name": "Batterij SOC" }, + "battery_soc_pack_1": { + "name": "Batterij SOC Pack 1" + }, + "battery_soc_pack_2": { + "name": "Batterij SOC Pack 2" + }, + "battery_soc_pack_3": { + "name": "Batterij SOC Pack 3" + }, + "battery_soc_pack_4": { + "name": "Batterij SOC Pack 4" + }, + "battery_soc_pack_5": { + "name": "Batterij SOC Pack 5" + }, + "battery_soc_pack_6": { + "name": "Batterij SOC Pack 6" + }, "battery_total_energy": { "name": "Totale Batterijenergie" }, diff --git a/mkdocs.yml b/mkdocs.yml index 3eac883f..d16db654 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: Omnibattery -site_description: Home Assistant custom integration for pluggable solar batteries (Marstek Venus and Zendure SolarFlow) +site_description: Home Assistant custom integration for monitoring and controlling multiple home-battery brands site_author: ffunes site_url: https://ffunes.github.io/omnibattery/ repo_url: https://github.com/ffunes/omnibattery @@ -147,6 +147,7 @@ nav: - Marstek: configuration/batteries/marstek.md - Zendure: configuration/batteries/zendure.md - Anker SOLIX: configuration/batteries/anker.md + - Huawei: configuration/batteries/huawei.md - Sessy: configuration/batteries/sessy.md - Hoymiles MQTT: configuration/batteries/hoymiles.md - Time slots: configuration/time-slots.md diff --git a/site-docs/configuration/batteries/huawei.es.md b/site-docs/configuration/batteries/huawei.es.md new file mode 100644 index 00000000..846eea0d --- /dev/null +++ b/site-docs/configuration/batteries/huawei.es.md @@ -0,0 +1,108 @@ +# Huawei SUN2000 + LUNA2000 + +Omnibattery se conecta a una Huawei LUNA2000 a través de su inversor híbrido +SUN2000 mediante Modbus TCP. La telemetría siempre se lee directamente por +Modbus. Las órdenes de potencia pueden usar los servicios de la integración +**Huawei Solar** (opción predeterminada) o escrituras Modbus directas opcionales. + +!!! warning "Hardware probado" + La compatibilidad se ha validado en una única instalación trifásica europea + con un SUN2000-8K-MAP0, un módulo de potencia LUNA2000-10KW-C1 y dos packs + LUNA2000-7-E1. Otros modelos SUN2000, configuraciones de hardware y versiones + de firmware aún no se han probado. + +## Elegir el método de control + +Los inversores Huawei solo aceptan una conexión Modbus simultánea. Elige el +esquema de conexión antes de añadir la batería: + +| Método de control | Requisitos | Endpoint Modbus en Omnibattery | +|---|---|---| +| **Servicios de Huawei Solar** (predeterminado) | Instala y configura la [integración Huawei Solar](https://github.com/wlcrs/huawei_solar), y comprueba que su dispositivo de batería LUNA2000 aparece en Home Assistant. | Un [proxy Modbus](https://github.com/Akulatraxas/ha-modbusproxy) compartido por Huawei Solar y Omnibattery. | +| **Escrituras Modbus directas** | Huawei Solar no es necesaria. Esta vía solo se ha validado en una instalación, por lo que conviene empezar con la opción predeterminada cuando sea posible. | El propio inversor si Omnibattery es el único cliente Modbus; en caso contrario, un proxy Modbus compartido. | + +!!! danger "No abras dos conexiones directas" + No conectes Huawei Solar, Omnibattery, evcc u otro cliente directamente al + inversor al mismo tiempo. Coloca un proxy Modbus delante y dirige todos los + clientes al proxy. + +Para la vía predeterminada, configura primero Huawei Solar y verifica que su +dispositivo de batería esté disponible. Configura el inversor como dispositivo +de destino del proxy y usa la dirección y el puerto del proxy en ambas +integraciones. Omnibattery no necesita autenticación Modbus. + +## Añadir la batería + +En el asistente de configuración de Omnibattery, selecciona **Huawei SUN2000 + +LUNA2000** y completa el formulario de conexión. + +| Campo | Descripción | Por defecto | +|---|---|---| +| **Nombre** | Nombre usado para el dispositivo de batería | `Huawei LUNA2000 1` | +| **Dirección IP** | Dirección del proxy Modbus, o del inversor cuando Omnibattery sea su único cliente | — | +| **Puerto Modbus** | Puerto TCP expuesto por el proxy o el inversor | `502` | +| **ID de esclavo Modbus** | ID de unidad del inversor SUN2000, no el del gestor de energía EMMA ni el de un cargador. Déjalo vacío para buscarlo automáticamente. | Búsqueda automática | +| **Escrituras Modbus directas** | Envía las órdenes de potencia directamente en lugar de usar los servicios de Huawei Solar | Desactivadas | +| **Dispositivo de batería Huawei Solar** | Dispositivo LUNA2000 creado por Huawei Solar. Es obligatorio si las escrituras directas están desactivadas; déjalo vacío si están activadas. | — | + +La búsqueda automática del ID de esclavo tarda unos 15 segundos. Si encuentra +un único inversor con batería, Omnibattery lo selecciona. Si encuentra varios +en una instalación en cascada, elige el inversor al que pertenece esta +LUNA2000. Añade otra batería con el otro ID de esclavo por cada sistema de +almacenamiento adicional. + +El asistente comprueba que el dispositivo de batería Huawei Solar seleccionado +pertenezca al mismo inversor que responde en el ID de esclavo Modbus. Así evita +leer la telemetría de un inversor y enviar las órdenes a otro en una cascada. + +## Límites de potencia y SOC + +Durante la prueba de conexión, Omnibattery lee los límites actuales de carga y +descarga de la batería. Esos valores sirven como punto de partida del siguiente +formulario. Puedes reducirlos para tu instalación; el rango superior está +limitado por la potencia activa máxima del inversor porque el límite que +comunica la batería puede cambiar al añadir packs. Cada orden se sigue +restringiendo al límite de hardware disponible en ese momento. La capacidad +nominal se lee automáticamente de la batería y no requiere ningún dato manual. + +La página de límites comunes también incluye: + +- SOC máximo: 80–100 % (por defecto `100 %`); +- SOC mínimo: 0–30 % (por defecto `10 %`); +- histéresis de carga obligatoria (mínimo 2 %); +- umbral de backup offgrid. + +Huawei solo admite un rango más estrecho en sus registros persistentes de corte +por SOC. Por ello, Omnibattery aplica por software todo el rango configurado y +solo usa los cortes de hardware cuando el valor se puede representar. + +LUNA2000 ofrece datos por pack, pero no las tensiones de cada celda. Por tanto, +el monitor de equilibrio de celdas y la reducción de carga al 100 % basada en +tensión no están disponibles. Para los controles comunes en tiempo de ejecución +y los límites del sistema, consulta la [configuración de baterías](index.md). + +## Comportamiento específico de Huawei + +Como la batería y los strings fotovoltaicos comparten el inversor SUN2000, la +potencia de descarga disponible disminuye cuando la producción solar se acerca +al límite del inversor. Omnibattery lo tiene en cuenta automáticamente. + +Una orden de `0 W` devuelve el control de la batería al modo de trabajo propio +del inversor; no mantiene la LUNA2000 en reposo. Por tanto, el inversor puede +reanudar su estrategia de autoconsumo. Al activar **Control Manual de Batería**, +la batería también queda en manos del inversor después de detenerse el +controlador automático. + +## Solución de problemas + +| Problema | Comprobación | +|---|---| +| **No se puede conectar** | Verifica la dirección y el puerto, comprueba que el proxy puede alcanzar el inversor y asegúrate de que ningún cliente evita el proxy. | +| **Se alcanza el inversor, pero no se encuentra la batería** | Deja vacío el ID de esclavo para repetir la búsqueda o comprueba que el ID elegido pertenezca al inversor que tiene conectada la LUNA2000. | +| **No aparece el dispositivo de batería Huawei Solar** | Instala y configura Huawei Solar, o activa las escrituras Modbus directas y deja vacío el campo del dispositivo. | +| **El dispositivo de batería no coincide con el inversor** | En una cascada, selecciona el dispositivo Huawei Solar y el ID de esclavo que pertenezcan al mismo inversor. | +| **El formulario parece detenerse tras enviarlo** | Una búsqueda automática del ID de esclavo suele tardar unos 15 segundos. | + +Para consultar el firmware verificado, el mapa de registros y las limitaciones +de la implementación, consulta la [evaluación técnica del driver de +Huawei](../../reference/driver-assessment-huawei.md). diff --git a/site-docs/configuration/batteries/huawei.md b/site-docs/configuration/batteries/huawei.md new file mode 100644 index 00000000..348103b6 --- /dev/null +++ b/site-docs/configuration/batteries/huawei.md @@ -0,0 +1,103 @@ +# Huawei SUN2000 + LUNA2000 + +Omnibattery connects to a Huawei LUNA2000 through its SUN2000 hybrid inverter +over Modbus TCP. Telemetry is always read directly from Modbus. Power commands +can use the **Huawei Solar** integration's services (the default) or optional +direct Modbus writes. + +!!! warning "Tested hardware" + Support has been validated on one EU three-phase installation with a + SUN2000-8K-MAP0, a LUNA2000-10KW-C1 power module and two LUNA2000-7-E1 + packs. Other SUN2000 models, hardware layouts and firmware versions remain + untested. + +## Choose the control method + +Huawei inverters accept only one Modbus connection at a time. Choose the +connection layout before adding the battery: + +| Control method | Requirements | Modbus endpoint in Omnibattery | +|---|---|---| +| **Huawei Solar services** (default) | Install and configure the [Huawei Solar integration](https://github.com/wlcrs/huawei_solar), and make sure its LUNA2000 battery device appears in Home Assistant. | A [Modbus proxy](https://github.com/Akulatraxas/ha-modbusproxy) shared by Huawei Solar and Omnibattery. | +| **Direct Modbus writes** | Huawei Solar is not required. This path has only been validated on one installation, so start with the default path when possible. | The inverter itself if Omnibattery is the only Modbus client; otherwise a shared Modbus proxy. | + +!!! danger "Do not open two direct connections" + Do not point Huawei Solar, Omnibattery, evcc or another client directly at + the inverter at the same time. Put a Modbus proxy in front of it and point + every client at the proxy. + +For the default path, configure Huawei Solar first and verify that its battery +device is available. Configure the proxy with the inverter as its upstream +device, then use the proxy's address and port in both integrations. Omnibattery +does not require Modbus authentication. + +## Add the battery + +In the Omnibattery setup wizard, select **Huawei SUN2000 + LUNA2000** and fill +in the connection form. + +| Field | Description | Default | +|---|---|---| +| **Name** | Name used for the battery device | `Huawei LUNA2000 1` | +| **IP address** | Address of the Modbus proxy, or of the inverter when Omnibattery is its only client | — | +| **Modbus port** | TCP port exposed by the proxy or inverter | `502` | +| **Modbus slave ID** | Unit ID of the SUN2000 inverter, not the EMMA energy manager or a charger. Leave it empty to search automatically. | Automatic search | +| **Direct Modbus writes** | Send power commands directly instead of using Huawei Solar services | Disabled | +| **Huawei Solar battery device** | LUNA2000 device created by Huawei Solar. Required when direct writes are disabled; leave it empty when they are enabled. | — | + +The automatic slave-ID search takes about 15 seconds. If one inverter with a +battery is found, Omnibattery selects it. If several are found on a cascaded +installation, choose the inverter to which this LUNA2000 belongs. Add another +battery entry with the other slave ID for each additional storage system. + +The wizard checks that the selected Huawei Solar battery device belongs to the +same inverter that answers at the Modbus slave ID. This prevents telemetry from +one inverter and commands from being sent to another in a cascade. + +## Power and SOC limits + +During the connection test, Omnibattery reads the battery's current charge and +discharge limits. Those values seed the next form. You can reduce them for your +installation; the upper range is bounded by the inverter's maximum active power +because the battery's reported limit can change when packs are added. Every +command is still clamped to the live hardware limit. Nominal capacity is read +from the battery automatically and requires no manual input. + +The common limits page also includes: + +- maximum SOC: 80–100% (default `100%`); +- minimum SOC: 0–30% (default `10%`); +- mandatory charge hysteresis (minimum 2%); +- backup offgrid threshold. + +Huawei only accepts a narrower range in its persistent SOC cutoff registers, so +Omnibattery enforces the complete configured range in software and uses the +hardware cutoffs only when the value can be represented. + +LUNA2000 exposes pack data but not individual cell voltages. Cell-balance +monitoring and the 100% cell-voltage taper are therefore unavailable. For the +common runtime controls and system limits, see [Battery configuration](index.md). + +## Behaviour specific to Huawei + +Because the battery and PV strings share the SUN2000 inverter, available +discharge power decreases as PV output approaches the inverter limit. +Omnibattery accounts for this automatically. + +A `0 W` command releases the battery back to the inverter's own working mode; +it does not hold the LUNA2000 idle. The inverter may therefore resume its +self-consumption strategy. Enabling **Battery Manual Control** likewise hands +the battery back to the inverter after the automatic controller stops. + +## Troubleshooting + +| Problem | Check | +|---|---| +| **Cannot connect** | Verify the address and port, confirm that the proxy can reach the inverter, and make sure no client bypasses the proxy. | +| **Inverter reached, but no battery found** | Leave the slave ID empty to search again, or verify that the chosen ID belongs to the inverter with the LUNA2000 attached. | +| **Huawei Solar battery device is missing** | Install and configure Huawei Solar, or enable direct Modbus writes and leave the device field empty. | +| **Battery device does not match the inverter** | In a cascade, select the Huawei Solar device and slave ID belonging to the same inverter. | +| **The form appears to pause after submission** | An automatic slave-ID scan normally takes about 15 seconds. | + +For verified firmware, register mappings and implementation limitations, see +the [Huawei driver assessment](../../reference/driver-assessment-huawei.md). diff --git a/site-docs/configuration/batteries/index.es.md b/site-docs/configuration/batteries/index.es.md index bbeadc78..83cda1e3 100644 --- a/site-docs/configuration/batteries/index.es.md +++ b/site-docs/configuration/batteries/index.es.md @@ -13,6 +13,7 @@ parte de los controles en tiempo de ejecución son comunes. | **Marstek** | Modbus TCP, Modbus RTU o puente LilyGo/ESPHome | [Marstek](marstek.md) | | **Zendure** | API HTTP local | [Zendure](zendure.md) | | **Anker SOLIX** | Modbus TCP | [Anker SOLIX](anker.md) | +| **Huawei SUN2000 + LUNA2000** | Modbus TCP, con servicios Huawei Solar o control directo | [Huawei](huawei.md) | | **Sessy** | API HTTP local mediante el dongle de Sessy | [Sessy](sessy.md) | | **Hoymiles MS-A2 / HiBattery** | MQTT mediante Home Assistant | [Hoymiles MQTT](hoymiles.md) | diff --git a/site-docs/configuration/batteries/index.md b/site-docs/configuration/batteries/index.md index f973cd5d..67402459 100644 --- a/site-docs/configuration/batteries/index.md +++ b/site-docs/configuration/batteries/index.md @@ -12,6 +12,7 @@ loop, dashboard, predictive charging and most runtime controls are shared. | **Marstek** | Modbus TCP, Modbus RTU or a LilyGo/ESPHome bridge | [Marstek](marstek.md) | | **Zendure** | Local HTTP API | [Zendure](zendure.md) | | **Anker SOLIX** | Modbus TCP | [Anker SOLIX](anker.md) | +| **Huawei SUN2000 + LUNA2000** | Modbus TCP, with Huawei Solar services or direct control | [Huawei](huawei.md) | | **Sessy** | Local HTTP API through the Sessy dongle | [Sessy](sessy.md) | | **Hoymiles MS-A2 / HiBattery** | MQTT through Home Assistant | [Hoymiles MQTT](hoymiles.md) | diff --git a/site-docs/features/consumption-estimate.es.md b/site-docs/features/consumption-estimate.es.md index 88748e57..2986d121 100644 --- a/site-docs/features/consumption-estimate.es.md +++ b/site-docs/features/consumption-estimate.es.md @@ -152,7 +152,8 @@ de esas horas. El perfil combina muestras del mismo día de la semana, del mismo tipo laborable/fin de semana y globales. Los días recientes pesan `1,0`, `0,75`, `0,5` y `0,25`. Solo se considera maduro con al menos siete días válidos, dos muestras -para el 75 % de los intervalos solicitados, un 80 % de cobertura del rango y una +—del mismo día de la semana o, en su defecto, del mismo tipo laborable/fin de +semana— para el 75 % de los intervalos solicitados, un 80 % de cobertura del rango y una muestra de menos de siete días. Si no es maduro, se usa automáticamente la media diaria heredada o la estimación por potencia actual, según el consumidor. @@ -163,9 +164,13 @@ puntual no elimine la demanda doméstica que aún cabe esperar durante el día. Tras arrancar, el backfill del Recorder se ejecuta en segundo plano con una consulta por cada fuente configurada. Los datos crudos están aislados en -`omnibattery..consumption_profile`; cambiar la fuente, los ajustes de -cargas o la zona horaria de Home Assistant invalida el perfil y comienza un -aprendizaje nuevo. +`omnibattery..consumption_profile`. Nada de lo que configures borra lo +aprendido: cambiar la fuente o un ajuste de cargas sólo rompe la continuidad del +muestreo y lanza un backfill de los días que falten, y cambiar la zona horaria +de Home Assistant reubica los días guardados según el desfase entre ambas zonas +en vez de descartarlos (los dos días de los extremos se quedan con parte de sus +horas y el backfill los vuelve a pedir). Sólo una zona horaria guardada que ya +no exista obliga a aprender de cero. El sensor de diagnóstico `sensor.omnibattery_expected_home_consumption_profile` expone la previsión, diff --git a/site-docs/features/consumption-estimate.md b/site-docs/features/consumption-estimate.md index 764c7630..5eddf4d1 100644 --- a/site-docs/features/consumption-estimate.md +++ b/site-docs/features/consumption-estimate.md @@ -149,7 +149,8 @@ schedule battery charging but do not remove the home's load from the day. The profile uses a hierarchy of matching weekday, weekday/weekend type and global samples. Recent days are weighted `1.0`, `0.75`, `0.5` and `0.25`. It is considered -mature only when it has at least seven valid days, at least two samples for 75% +mature only when it has at least seven valid days, at least two samples — of the +matching weekday or, failing that, of the matching weekday/weekend type — for 75% of the requested intervals, at least 80% coverage of the requested range and a sample no older than seven days. An immature profile automatically falls back to the legacy daily average or the current-rate estimate, depending on the caller. @@ -161,8 +162,13 @@ cannot erase household demand that can still be expected later in the day. Recorder backfill runs in the background after startup and uses one query per configured source. Raw profile data is isolated in -`omnibattery..consumption_profile`; changing the source, load -adjustments or Home Assistant timezone invalidates it and starts a fresh learn. +`omnibattery..consumption_profile`. Nothing you configure erases what +it has learned: changing the source or a load adjustment only breaks sample +continuity and triggers a backfill of the days still missing, and changing the +Home Assistant timezone re-bins the stored days by the offset between the two +zones instead of dropping them (the two edge days keep only part of their hours +and are re-fetched by backfill). Only a stored timezone that no longer exists +forces a fresh learn. The diagnostic sensor `sensor.omnibattery_expected_home_consumption_profile` exposes the current diff --git a/site-docs/index.es.md b/site-docs/index.es.md index 6d905ca1..095021ad 100644 --- a/site-docs/index.es.md +++ b/site-docs/index.es.md @@ -5,6 +5,7 @@ - **Marstek** Venus E/C (v2/v3), Venus A y Venus D mediante Modbus TCP, Modbus RTU o un puente LilyGo RS485/ESPHome. - **Zendure** SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus y 800 mediante HTTP local. - **Anker SOLIX** Solarbank Max AC y Solarbank 4 E5000 Pro mediante Modbus TCP. +- **Huawei** SUN2000 + LUNA2000 mediante Modbus TCP (validado en SUN2000-8K-MAP0). - **Sessy** Home Battery mediante la API local de su dongle (se buscan testers). - **Hoymiles** MS-A2 mediante la integración MQTT configurada en Home Assistant. @@ -24,7 +25,7 @@ - :material-brand_family: **Multi-marca** - Combina baterías Marstek, Zendure, Anker SOLIX, Sessy y Hoymiles en una misma instalación, compartiendo el bucle de control, las entidades de sistema y las funciones de gestión energética. + Combina baterías Marstek, Zendure, Anker SOLIX, Huawei, Sessy y Hoymiles en una misma instalación, compartiendo el bucle de control, las entidades de sistema y las funciones de gestión energética. - :material-view-dashboard: **Dashboard integrado** @@ -51,7 +52,7 @@ El panel se instala automáticamente como un panel lateral de Home Assistant; no - **Controlador PD (Zero Export/Import)**: ajusta en tiempo real la potencia de la batería para mantener el intercambio con la red próximo a cero. - **Perfiles PD de un clic y sensor de calidad de control**: selecciona una respuesta de Muy suave a Muy agresiva y consulta el veredicto de calidad para saber si la regulación es estable, oscilante o lenta. - **Modo de seguimiento directo sin PD** (opt-in): la batería sigue el sensor de consumo 1:1 en cada ciclo — sin integral, derivada, suavizado ni limitador de rampa — para instalaciones que prefieren seguimiento directo al controlador PD. -- **Compatibilidad multi-marca**: combina baterías compatibles Marstek, Zendure, Anker SOLIX, Sessy y Hoymiles en la misma instalación. +- **Compatibilidad multi-marca**: combina baterías compatibles Marstek, Zendure, Anker SOLIX, Huawei, Sessy y Hoymiles en la misma instalación. - **Carga predictiva**: tres modos (franja horaria, precio dinámico, precio en tiempo real — incluyendo Tibber) que cargan desde la red solo cuando el balance energético lo requiere. Utiliza una media móvil de 7 días del consumo real del hogar para decidir si es necesario cargar desde la red. - **Gestión multi-batería**: selección inteligente con prioridades de SOC, histéresis de energía y eficiencia por zona de operación. - **Franjas horarias**: controlan de forma independiente las ventanas de carga y descarga, con parámetros de SOC y potencia por franja. diff --git a/site-docs/index.md b/site-docs/index.md index db75fca4..04b6278d 100644 --- a/site-docs/index.md +++ b/site-docs/index.md @@ -5,6 +5,7 @@ - **Marstek** Venus E/C (v2/v3), Venus A and Venus D via Modbus TCP, Modbus RTU or a LilyGo RS485/ESPHome bridge. - **Zendure** SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus and 800 via local HTTP. - **Anker SOLIX** Solarbank Max AC and Solarbank 4 E5000 Pro via Modbus TCP. +- **Huawei** SUN2000 + LUNA2000 via Modbus TCP (validated on SUN2000-8K-MAP0). - **Sessy** Home Battery via its local dongle API (testers welcome). - **Hoymiles** MS-A2 via the MQTT integration configured in Home Assistant. @@ -24,7 +25,7 @@ - :material-brand_family: **Multi-brand** - Combine Marstek, Zendure, Anker SOLIX, Sessy and Hoymiles batteries in one installation while sharing the same control loop, system entities and energy-management features. + Combine Marstek, Zendure, Anker SOLIX, Huawei, Sessy and Hoymiles batteries in one installation while sharing the same control loop, system entities and energy-management features. - :material-view-dashboard: **Integrated dashboard** @@ -51,7 +52,7 @@ The panel installs automatically as a Home Assistant sidebar panel — no extra - **PD Controller (Zero Export/Import)**: adjusts battery power in real time to keep grid exchange close to zero. - **One-click PD profiles and control-quality sensor**: select a response from Very smooth to Very aggressive, then use the quality verdict to see whether regulation is stable, oscillating or sluggish. - **No-PD direct-tracking mode** (opt-in): the battery follows the consumption sensor 1:1 in a single cycle — no integral, derivative, smoothing or rate limiter — for installations that prefer raw tracking over the PD control law. -- **Multi-brand support**: combine compatible Marstek, Zendure, Anker SOLIX, Sessy and Hoymiles batteries in the same installation. +- **Multi-brand support**: combine compatible Marstek, Zendure, Anker SOLIX, Huawei, Sessy and Hoymiles batteries in the same installation. - **Predictive charging**: three modes (time slot, dynamic pricing, real-time price — including Tibber) that charge from the grid only when the energy balance requires it. Uses a 7-day rolling average of real household consumption to decide whether grid charging is needed. - **Multi-battery management**: smart selection with SOC priorities, energy hysteresis and efficiency zone operation. - **Time slots**: independently control charge and discharge windows, with per-slot SOC and power parameters. diff --git a/site-docs/installation.es.md b/site-docs/installation.es.md index a2fe8696..726c30ec 100644 --- a/site-docs/installation.es.md +++ b/site-docs/installation.es.md @@ -12,6 +12,7 @@ compatible. Los adaptadores y puentes solo son necesarios cuando se indican. | **Marstek Venus E/C (v2/v3), Venus A, Venus D** | Modbus TCP; Modbus RTU por USB–RS485; o puente LilyGo RS485/ESPHome *(Venus E v2)* | **Modbus TCP:** Venus E v2 necesita un conversor RS485 → TCP (p. ej. Elfin-EW11); Venus E v3, Venus A y Venus D usan Ethernet nativo. **Modbus RTU:** adaptador USB–RS485. **ESPHome:** el puente LilyGo debe exponer sus entidades requeridas en Home Assistant. | | **Zendure SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus, 800** | API HTTP local | Mantén **HEMS desactivado** en la aplicación de Zendure. Si está activo, HEMS sobrescribe la consigna manual de potencia de Omnibattery. | | **Anker SOLIX Solarbank Max AC, 4 E5000 Pro** | Modbus TCP | Activa **Third-Party Control** en la aplicación de Anker. Solo puede conectarse un cliente Modbus a la vez. | +| **Huawei SUN2000 + LUNA2000** | Modbus TCP a través del inversor SUN2000 | La vía de control predeterminada necesita la integración Huawei Solar y un proxy Modbus compartido. Las escrituras directas son opcionales. Consulta la [guía de instalación de Huawei](configuration/batteries/huawei.md). | | **Sessy Home Battery** | API HTTP local mediante el dongle de Sessy | El dongle debe ser accesible desde Home Assistant. Introduce su IP/nombre de host, puerto y credenciales; el puerto predeterminado es `80`. | | **Hoymiles MS-A2 / HiBattery** | MQTT mediante la integración MQTT configurada en Home Assistant | Hace falta un broker MQTT local operativo (por ejemplo, Mosquitto; se puede reutilizar uno existente). Activa **MQTT Service** en S-Miles Home y asegúrate de que la batería puede alcanzar el broker. | | **Sensor de red** | Entidad de Home Assistant | Sensor que mida el consumo total de la red (p. ej. Shelly EM3, Neurio o integración de contador inteligente). | @@ -27,6 +28,7 @@ compatible. Los adaptadores y puentes solo son necesarios cuando se indican. - Home Assistant **2024.1.0** o superior - Solo para **baterías Hoymiles mediante MQTT**: la integración MQTT de Home Assistant y un broker MQTT local operativo. Omnibattery usa el broker a través de Home Assistant; no instala uno. +- Para el método de control **Huawei** predeterminado: la integración Huawei Solar. No es necesaria si se activan las escrituras Modbus directas. - (Opcional) Sensor de previsión solar para la carga predictiva (Solcast, Forecast.Solar, etc.) ### Red diff --git a/site-docs/installation.md b/site-docs/installation.md index 6eec21cb..f918e862 100644 --- a/site-docs/installation.md +++ b/site-docs/installation.md @@ -12,6 +12,7 @@ battery. Adapters and bridges are only required where noted. | **Marstek Venus E/C (v2/v3), Venus A, Venus D** | Modbus TCP; Modbus RTU over USB–RS485; or LilyGo RS485/ESPHome bridge *(Venus E v2)* | **Modbus TCP:** Venus E v2 needs an RS485 → TCP converter (e.g. Elfin-EW11); Venus E v3, Venus A and Venus D use native Ethernet. **Modbus RTU:** USB–RS485 adapter. **ESPHome:** the LilyGo bridge must expose its required entities in Home Assistant. | | **Zendure SolarFlow 4000 Mix Pro, 4000 Mix AC+, 2400 AC+, 2400 AC Pro, 1600 AC+, 800 Pro, 800 Plus, 800** | Local HTTP API | Keep **HEMS disabled** in the Zendure app. HEMS overrides Omnibattery's manual power setpoint when enabled. | | **Anker SOLIX Solarbank Max AC, 4 E5000 Pro** | Modbus TCP | Enable **Third-Party Control** in the Anker app. Only one Modbus client can connect at a time. | +| **Huawei SUN2000 + LUNA2000** | Modbus TCP through the SUN2000 inverter | The default control path requires the Huawei Solar integration and a shared Modbus proxy. Direct writes are optional. See the [Huawei setup guide](configuration/batteries/huawei.md). | | **Sessy Home Battery** | Local HTTP API through the Sessy dongle | The dongle must be reachable from Home Assistant. Enter its IP/hostname, port and dongle credentials; port `80` is the default. | | **Hoymiles MS-A2 / HiBattery** | MQTT through Home Assistant's configured MQTT integration | A working local MQTT broker is required (for example, Mosquitto; an existing broker can be reused). Enable **MQTT Service** in S-Miles Home and make the broker reachable from the battery. | | **Grid sensor** | Home Assistant entity | Sensor measuring total grid consumption (e.g. Shelly EM3, Neurio, smart-meter integration). | @@ -26,6 +27,7 @@ battery. Adapters and bridges are only required where noted. - Home Assistant **2024.1.0** or later - For **Hoymiles MQTT batteries** only: the Home Assistant MQTT integration and a working local MQTT broker. Omnibattery uses the broker through Home Assistant; it does not install one. +- For the default **Huawei** control method: the Huawei Solar integration. It is not required when direct Modbus writes are enabled. - (Optional) Solar forecast sensor for predictive charging (Solcast, Forecast.Solar, etc.) ### Network diff --git a/site-docs/reference/registers.md b/site-docs/reference/registers.md index 4ab93cbb..c29d376f 100644 --- a/site-docs/reference/registers.md +++ b/site-docs/reference/registers.md @@ -12,7 +12,13 @@ | bluetooth_status | Bluetooth connectivity/status | uint16 | 2 | - | - | 30301 | 30301 | 30301 | 30301 | | wifi_status (binary) | WiFi connected (0/1) | uint16 | 2 | 1 | - | 30300 | 30300 | 30300 | 30300 | | cloud_status (binary) | Cloud connected (0/1) | uint16 | 2 | 1 | - | 30302 | 30302 | 30302 | 30302 | -| battery_soc | State of charge | uint16 | 2 | 0.1/1 | % | 34002 | 34002 | 32104 | 34002 | +| battery_soc | State of charge | uint16 | 2 | 1 | % | 32104 | 32104 | 32104 | 34002 | +| battery_soc_pack_1 | Pack 1 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34002 | 34002 | | | +| battery_soc_pack_2 | Pack 2 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34102 | 34102 | | | +| battery_soc_pack_3 | Pack 3 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34202 | 34202 | | | +| battery_soc_pack_4 | Pack 4 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34302 | 34302 | | | +| battery_soc_pack_5 | Pack 5 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34402 | 34402 | | | +| battery_soc_pack_6 | Pack 6 state of charge (issue #350) | uint16 | 2 | 0.1 | % | 34502 | 34502 | | | | battery_total_energy | Total stored energy | uint16 | 2 | 0.001 | kWh | 32105 | 32105 | 32105 | 32105 | | battery_voltage | Battery voltage | uint16 | 2 | 0.01 | V | 30100 | 30100 | 32100 | 30100 | | battery_current | Battery current | int16 | 2 | 0.1/0.01| A | 30101 | 30101 | 32101 | 30101 | @@ -53,71 +59,71 @@ | battery_1_cell_14_voltage | Battery pack 1 cell 14 voltage | int16 | 2 | 0.001 | V | | 34031 | | 34031 | | battery_1_cell_15_voltage | Battery pack 1 cell 15 voltage | int16 | 2 | 0.001 | V | | 34032 | | 34032 | | battery_1_cell_16_voltage | Battery pack 1 cell 16 voltage | int16 | 2 | 0.001 | V | | 34033 | | 34033 | -| battery_2_cell_1_voltage | Battery pack 2 cell 1 voltage | int16 | 2 | 0.001 | V | 34031 | | | | -| battery_2_cell_2_voltage | Battery pack 2 cell 2 voltage | int16 | 2 | 0.001 | V | 34032 | | | | -| battery_2_cell_3_voltage | Battery pack 2 cell 3 voltage | int16 | 2 | 0.001 | V | 34033 | | | | -| battery_2_cell_4_voltage | Battery pack 2 cell 4 voltage | int16 | 2 | 0.001 | V | 34034 | | | | -| battery_2_cell_5_voltage | Battery pack 2 cell 5 voltage | int16 | 2 | 0.001 | V | 34035 | | | | -| battery_2_cell_6_voltage | Battery pack 2 cell 6 voltage | int16 | 2 | 0.001 | V | 34036 | | | | -| battery_2_cell_7_voltage | Battery pack 2 cell 7 voltage | int16 | 2 | 0.001 | V | 34037 | | | | -| battery_2_cell_8_voltage | Battery pack 2 cell 8 voltage | int16 | 2 | 0.001 | V | 34038 | | | | -| battery_2_cell_9_voltage | Battery pack 2 cell 9 voltage | int16 | 2 | 0.001 | V | 34039 | | | | -| battery_2_cell_10_voltage | Battery pack 2 cell 10 voltage | int16 | 2 | 0.001 | V | 34040 | | | | -| battery_2_cell_11_voltage | Battery pack 2 cell 11 voltage | int16 | 2 | 0.001 | V | 34041 | | | | -| battery_2_cell_12_voltage | Battery pack 2 cell 12 voltage | int16 | 2 | 0.001 | V | 34042 | | | | -| battery_2_cell_13_voltage | Battery pack 2 cell 13 voltage | int16 | 2 | 0.001 | V | 34043 | | | | -| battery_3_cell_1_voltage | Battery pack 3 cell 1 voltage | int16 | 2 | 0.001 | V | 34044 | | | | -| battery_3_cell_2_voltage | Battery pack 3 cell 2 voltage | int16 | 2 | 0.001 | V | 34045 | | | | -| battery_3_cell_3_voltage | Battery pack 3 cell 3 voltage | int16 | 2 | 0.001 | V | 34046 | | | | -| battery_3_cell_4_voltage | Battery pack 3 cell 4 voltage | int16 | 2 | 0.001 | V | 34047 | | | | -| battery_3_cell_5_voltage | Battery pack 3 cell 5 voltage | int16 | 2 | 0.001 | V | 34048 | | | | -| battery_3_cell_6_voltage | Battery pack 3 cell 6 voltage | int16 | 2 | 0.001 | V | 34049 | | | | -| battery_3_cell_7_voltage | Battery pack 3 cell 7 voltage | int16 | 2 | 0.001 | V | 34050 | | | | -| battery_3_cell_8_voltage | Battery pack 3 cell 8 voltage | int16 | 2 | 0.001 | V | 34051 | | | | -| battery_3_cell_9_voltage | Battery pack 3 cell 9 voltage | int16 | 2 | 0.001 | V | 34052 | | | | -| battery_3_cell_10_voltage | Battery pack 3 cell 10 voltage | int16 | 2 | 0.001 | V | 34053 | | | | -| battery_3_cell_11_voltage | Battery pack 3 cell 11 voltage | int16 | 2 | 0.001 | V | 34054 | | | | -| battery_3_cell_12_voltage | Battery pack 3 cell 12 voltage | int16 | 2 | 0.001 | V | 34055 | | | | -| battery_3_cell_13_voltage | Battery pack 3 cell 13 voltage | int16 | 2 | 0.001 | V | 34056 | | | | -| battery_4_cell_1_voltage | Battery pack 4 cell 1 voltage | int16 | 2 | 0.001 | V | 34057 | | | | -| battery_4_cell_2_voltage | Battery pack 4 cell 2 voltage | int16 | 2 | 0.001 | V | 34058 | | | | -| battery_4_cell_3_voltage | Battery pack 4 cell 3 voltage | int16 | 2 | 0.001 | V | 34059 | | | | -| battery_4_cell_4_voltage | Battery pack 4 cell 4 voltage | int16 | 2 | 0.001 | V | 34060 | | | | -| battery_4_cell_5_voltage | Battery pack 4 cell 5 voltage | int16 | 2 | 0.001 | V | 34061 | | | | -| battery_4_cell_6_voltage | Battery pack 4 cell 6 voltage | int16 | 2 | 0.001 | V | 34062 | | | | -| battery_4_cell_7_voltage | Battery pack 4 cell 7 voltage | int16 | 2 | 0.001 | V | 34063 | | | | -| battery_4_cell_8_voltage | Battery pack 4 cell 8 voltage | int16 | 2 | 0.001 | V | 34064 | | | | -| battery_4_cell_9_voltage | Battery pack 4 cell 9 voltage | int16 | 2 | 0.001 | V | 34065 | | | | -| battery_4_cell_10_voltage | Battery pack 4 cell 10 voltage | int16 | 2 | 0.001 | V | 34066 | | | | -| battery_4_cell_11_voltage | Battery pack 4 cell 11 voltage | int16 | 2 | 0.001 | V | 34067 | | | | -| battery_4_cell_12_voltage | Battery pack 4 cell 12 voltage | int16 | 2 | 0.001 | V | 34068 | | | | -| battery_4_cell_13_voltage | Battery pack 4 cell 13 voltage | int16 | 2 | 0.001 | V | 34069 | | | | -| battery_5_cell_1_voltage | Battery pack 5 cell 1 voltage | int16 | 2 | 0.001 | V | 34070 | | | | -| battery_5_cell_2_voltage | Battery pack 5 cell 2 voltage | int16 | 2 | 0.001 | V | 34071 | | | | -| battery_5_cell_3_voltage | Battery pack 5 cell 3 voltage | int16 | 2 | 0.001 | V | 34072 | | | | -| battery_5_cell_4_voltage | Battery pack 5 cell 4 voltage | int16 | 2 | 0.001 | V | 34073 | | | | -| battery_5_cell_5_voltage | Battery pack 5 cell 5 voltage | int16 | 2 | 0.001 | V | 34074 | | | | -| battery_5_cell_6_voltage | Battery pack 5 cell 6 voltage | int16 | 2 | 0.001 | V | 34075 | | | | -| battery_5_cell_7_voltage | Battery pack 5 cell 7 voltage | int16 | 2 | 0.001 | V | 34076 | | | | -| battery_5_cell_8_voltage | Battery pack 5 cell 8 voltage | int16 | 2 | 0.001 | V | 34077 | | | | -| battery_5_cell_9_voltage | Battery pack 5 cell 9 voltage | int16 | 2 | 0.001 | V | 34078 | | | | -| battery_5_cell_10_voltage | Battery pack 5 cell 10 voltage | int16 | 2 | 0.001 | V | 34079 | | | | -| battery_5_cell_11_voltage | Battery pack 5 cell 11 voltage | int16 | 2 | 0.001 | V | 34080 | | | | -| battery_5_cell_12_voltage | Battery pack 5 cell 12 voltage | int16 | 2 | 0.001 | V | 34081 | | | | -| battery_5_cell_13_voltage | Battery pack 5 cell 13 voltage | int16 | 2 | 0.001 | V | 34082 | | | | -| battery_6_cell_1_voltage | Battery pack 6 cell 1 voltage | int16 | 2 | 0.001 | V | 34083 | | | | -| battery_6_cell_2_voltage | Battery pack 6 cell 2 voltage | int16 | 2 | 0.001 | V | 34084 | | | | -| battery_6_cell_3_voltage | Battery pack 6 cell 3 voltage | int16 | 2 | 0.001 | V | 34085 | | | | -| battery_6_cell_4_voltage | Battery pack 6 cell 4 voltage | int16 | 2 | 0.001 | V | 34086 | | | | -| battery_6_cell_5_voltage | Battery pack 6 cell 5 voltage | int16 | 2 | 0.001 | V | 34087 | | | | -| battery_6_cell_6_voltage | Battery pack 6 cell 6 voltage | int16 | 2 | 0.001 | V | 34088 | | | | -| battery_6_cell_7_voltage | Battery pack 6 cell 7 voltage | int16 | 2 | 0.001 | V | 34089 | | | | -| battery_6_cell_8_voltage | Battery pack 6 cell 8 voltage | int16 | 2 | 0.001 | V | 34090 | | | | -| battery_6_cell_9_voltage | Battery pack 6 cell 9 voltage | int16 | 2 | 0.001 | V | 34091 | | | | -| battery_6_cell_10_voltage | Battery pack 6 cell 10 voltage | int16 | 2 | 0.001 | V | 34092 | | | | -| battery_6_cell_11_voltage | Battery pack 6 cell 11 voltage | int16 | 2 | 0.001 | V | 34093 | | | | -| battery_6_cell_12_voltage | Battery pack 6 cell 12 voltage | int16 | 2 | 0.001 | V | 34094 | | | | -| battery_6_cell_13_voltage | Battery pack 6 cell 13 voltage | int16 | 2 | 0.001 | V | 34095 | | | | +| battery_2_cell_1_voltage | Battery pack 2 cell 1 voltage | int16 | 2 | 0.001 | V | 34118 | | | | +| battery_2_cell_2_voltage | Battery pack 2 cell 2 voltage | int16 | 2 | 0.001 | V | 34119 | | | | +| battery_2_cell_3_voltage | Battery pack 2 cell 3 voltage | int16 | 2 | 0.001 | V | 34120 | | | | +| battery_2_cell_4_voltage | Battery pack 2 cell 4 voltage | int16 | 2 | 0.001 | V | 34121 | | | | +| battery_2_cell_5_voltage | Battery pack 2 cell 5 voltage | int16 | 2 | 0.001 | V | 34122 | | | | +| battery_2_cell_6_voltage | Battery pack 2 cell 6 voltage | int16 | 2 | 0.001 | V | 34123 | | | | +| battery_2_cell_7_voltage | Battery pack 2 cell 7 voltage | int16 | 2 | 0.001 | V | 34124 | | | | +| battery_2_cell_8_voltage | Battery pack 2 cell 8 voltage | int16 | 2 | 0.001 | V | 34125 | | | | +| battery_2_cell_9_voltage | Battery pack 2 cell 9 voltage | int16 | 2 | 0.001 | V | 34126 | | | | +| battery_2_cell_10_voltage | Battery pack 2 cell 10 voltage | int16 | 2 | 0.001 | V | 34127 | | | | +| battery_2_cell_11_voltage | Battery pack 2 cell 11 voltage | int16 | 2 | 0.001 | V | 34128 | | | | +| battery_2_cell_12_voltage | Battery pack 2 cell 12 voltage | int16 | 2 | 0.001 | V | 34129 | | | | +| battery_2_cell_13_voltage | Battery pack 2 cell 13 voltage | int16 | 2 | 0.001 | V | 34130 | | | | +| battery_3_cell_1_voltage | Battery pack 3 cell 1 voltage | int16 | 2 | 0.001 | V | 34218 | | | | +| battery_3_cell_2_voltage | Battery pack 3 cell 2 voltage | int16 | 2 | 0.001 | V | 34219 | | | | +| battery_3_cell_3_voltage | Battery pack 3 cell 3 voltage | int16 | 2 | 0.001 | V | 34220 | | | | +| battery_3_cell_4_voltage | Battery pack 3 cell 4 voltage | int16 | 2 | 0.001 | V | 34221 | | | | +| battery_3_cell_5_voltage | Battery pack 3 cell 5 voltage | int16 | 2 | 0.001 | V | 34222 | | | | +| battery_3_cell_6_voltage | Battery pack 3 cell 6 voltage | int16 | 2 | 0.001 | V | 34223 | | | | +| battery_3_cell_7_voltage | Battery pack 3 cell 7 voltage | int16 | 2 | 0.001 | V | 34224 | | | | +| battery_3_cell_8_voltage | Battery pack 3 cell 8 voltage | int16 | 2 | 0.001 | V | 34225 | | | | +| battery_3_cell_9_voltage | Battery pack 3 cell 9 voltage | int16 | 2 | 0.001 | V | 34226 | | | | +| battery_3_cell_10_voltage | Battery pack 3 cell 10 voltage | int16 | 2 | 0.001 | V | 34227 | | | | +| battery_3_cell_11_voltage | Battery pack 3 cell 11 voltage | int16 | 2 | 0.001 | V | 34228 | | | | +| battery_3_cell_12_voltage | Battery pack 3 cell 12 voltage | int16 | 2 | 0.001 | V | 34229 | | | | +| battery_3_cell_13_voltage | Battery pack 3 cell 13 voltage | int16 | 2 | 0.001 | V | 34230 | | | | +| battery_4_cell_1_voltage | Battery pack 4 cell 1 voltage | int16 | 2 | 0.001 | V | 34318 | | | | +| battery_4_cell_2_voltage | Battery pack 4 cell 2 voltage | int16 | 2 | 0.001 | V | 34319 | | | | +| battery_4_cell_3_voltage | Battery pack 4 cell 3 voltage | int16 | 2 | 0.001 | V | 34320 | | | | +| battery_4_cell_4_voltage | Battery pack 4 cell 4 voltage | int16 | 2 | 0.001 | V | 34321 | | | | +| battery_4_cell_5_voltage | Battery pack 4 cell 5 voltage | int16 | 2 | 0.001 | V | 34322 | | | | +| battery_4_cell_6_voltage | Battery pack 4 cell 6 voltage | int16 | 2 | 0.001 | V | 34323 | | | | +| battery_4_cell_7_voltage | Battery pack 4 cell 7 voltage | int16 | 2 | 0.001 | V | 34324 | | | | +| battery_4_cell_8_voltage | Battery pack 4 cell 8 voltage | int16 | 2 | 0.001 | V | 34325 | | | | +| battery_4_cell_9_voltage | Battery pack 4 cell 9 voltage | int16 | 2 | 0.001 | V | 34326 | | | | +| battery_4_cell_10_voltage | Battery pack 4 cell 10 voltage | int16 | 2 | 0.001 | V | 34327 | | | | +| battery_4_cell_11_voltage | Battery pack 4 cell 11 voltage | int16 | 2 | 0.001 | V | 34328 | | | | +| battery_4_cell_12_voltage | Battery pack 4 cell 12 voltage | int16 | 2 | 0.001 | V | 34329 | | | | +| battery_4_cell_13_voltage | Battery pack 4 cell 13 voltage | int16 | 2 | 0.001 | V | 34330 | | | | +| battery_5_cell_1_voltage | Battery pack 5 cell 1 voltage | int16 | 2 | 0.001 | V | 34418 | | | | +| battery_5_cell_2_voltage | Battery pack 5 cell 2 voltage | int16 | 2 | 0.001 | V | 34419 | | | | +| battery_5_cell_3_voltage | Battery pack 5 cell 3 voltage | int16 | 2 | 0.001 | V | 34420 | | | | +| battery_5_cell_4_voltage | Battery pack 5 cell 4 voltage | int16 | 2 | 0.001 | V | 34421 | | | | +| battery_5_cell_5_voltage | Battery pack 5 cell 5 voltage | int16 | 2 | 0.001 | V | 34422 | | | | +| battery_5_cell_6_voltage | Battery pack 5 cell 6 voltage | int16 | 2 | 0.001 | V | 34423 | | | | +| battery_5_cell_7_voltage | Battery pack 5 cell 7 voltage | int16 | 2 | 0.001 | V | 34424 | | | | +| battery_5_cell_8_voltage | Battery pack 5 cell 8 voltage | int16 | 2 | 0.001 | V | 34425 | | | | +| battery_5_cell_9_voltage | Battery pack 5 cell 9 voltage | int16 | 2 | 0.001 | V | 34426 | | | | +| battery_5_cell_10_voltage | Battery pack 5 cell 10 voltage | int16 | 2 | 0.001 | V | 34427 | | | | +| battery_5_cell_11_voltage | Battery pack 5 cell 11 voltage | int16 | 2 | 0.001 | V | 34428 | | | | +| battery_5_cell_12_voltage | Battery pack 5 cell 12 voltage | int16 | 2 | 0.001 | V | 34429 | | | | +| battery_5_cell_13_voltage | Battery pack 5 cell 13 voltage | int16 | 2 | 0.001 | V | 34430 | | | | +| battery_6_cell_1_voltage | Battery pack 6 cell 1 voltage | int16 | 2 | 0.001 | V | 34518 | | | | +| battery_6_cell_2_voltage | Battery pack 6 cell 2 voltage | int16 | 2 | 0.001 | V | 34519 | | | | +| battery_6_cell_3_voltage | Battery pack 6 cell 3 voltage | int16 | 2 | 0.001 | V | 34520 | | | | +| battery_6_cell_4_voltage | Battery pack 6 cell 4 voltage | int16 | 2 | 0.001 | V | 34521 | | | | +| battery_6_cell_5_voltage | Battery pack 6 cell 5 voltage | int16 | 2 | 0.001 | V | 34522 | | | | +| battery_6_cell_6_voltage | Battery pack 6 cell 6 voltage | int16 | 2 | 0.001 | V | 34523 | | | | +| battery_6_cell_7_voltage | Battery pack 6 cell 7 voltage | int16 | 2 | 0.001 | V | 34524 | | | | +| battery_6_cell_8_voltage | Battery pack 6 cell 8 voltage | int16 | 2 | 0.001 | V | 34525 | | | | +| battery_6_cell_9_voltage | Battery pack 6 cell 9 voltage | int16 | 2 | 0.001 | V | 34526 | | | | +| battery_6_cell_10_voltage | Battery pack 6 cell 10 voltage | int16 | 2 | 0.001 | V | 34527 | | | | +| battery_6_cell_11_voltage | Battery pack 6 cell 11 voltage | int16 | 2 | 0.001 | V | 34528 | | | | +| battery_6_cell_12_voltage | Battery pack 6 cell 12 voltage | int16 | 2 | 0.001 | V | 34529 | | | | +| battery_6_cell_13_voltage | Battery pack 6 cell 13 voltage | int16 | 2 | 0.001 | V | 34530 | | | | | mppt1_voltage | MPPT1 array voltage | uint16 | 2 | 0.1 | V | 30020 | 30020 | | | | mppt1_current | MPPT1 array current | uint16 | 2 | 0.1 | A | 30024 | 30024 | | | | mppt1_power | MPPT1 array power | uint16 | 2 | 0.1 | W | 30037 | 30037 | | | @@ -187,6 +193,11 @@ _Notes:_ - Columns `a`, `d`, `e_v12` and `e_v3` correspond to the YAML files under `custom_components/marstek_modbus/registers/`. +- Two Venus A/D entries deliberately **differ from that YAML**, which is wrong there (issue #350): + `battery_soc` is the aggregate at **32104** (the YAML's 34002 is pack 1's own SOC, and + `const/registers_va.py` has always read 32104); and the per-pack block is laid out with a + **stride of 100** — pack *n* starts at `34000 + 100·(n−1)`, SOC at offset `+2`, cells at `+18` — + not as one flat run, so the column `a` cell addresses above are renumbered accordingly. - `Bytes` shows the typical byte size for the key (each Modbus register = 2 bytes). - Blank cells mean that YAML does not define that key (or the value is calculated and has no direct Modbus register). - The `rs485_control_mode` switch (register 42000) uses write commands (command_on=21930, command_off=21947) to trigger RS485 control operations; use with caution. diff --git a/site-docs/troubleshooting.es.md b/site-docs/troubleshooting.es.md index 7c4e13d7..15c019b2 100644 --- a/site-docs/troubleshooting.es.md +++ b/site-docs/troubleshooting.es.md @@ -73,9 +73,10 @@ La integración monitoriza los registros `Alarm Status` y `Fault Status` de la b Es normal mientras el perfil de 28 días está aprendiendo o cuando los intervalos solicitados no cumplen su contrato de cobertura. Comprueba `sensor.omnibattery_expected_home_consumption_profile` y los diagnósticos de la -integración. Cambiar la fuente, un ajuste de cargas o la zona horaria -invalida deliberadamente el perfil guardado; después el backfill del Recorder lo -reconstruye en segundo plano. Los huecos de más de cinco minutos no se interpolan. +integración. Cambiar la fuente o un ajuste de cargas conserva todos los días +aprendidos, y un cambio de zona horaria los reubica según el desfase entre ambas +zonas; después el backfill del Recorder reconstruye en segundo plano lo que siga +faltando. Los huecos de más de cinco minutos no se interpolan. ### El perfil solar sigue inmaduro o usa fallback diff --git a/site-docs/troubleshooting.md b/site-docs/troubleshooting.md index 750952ea..1893a11b 100644 --- a/site-docs/troubleshooting.md +++ b/site-docs/troubleshooting.md @@ -73,9 +73,10 @@ The integration monitors the battery's `Alarm Status` and `Fault Status` registe This is expected while the 28-day profile is learning or when the requested intervals do not meet its coverage contract. Check `sensor.omnibattery_expected_home_consumption_profile` and the integration -diagnostics endpoint. A source, excluded-load adjustment or timezone change -intentionally invalidates the stored profile; Recorder backfill then -rebuilds it in the background. A gap longer than five minutes is not interpolated. +diagnostics endpoint. Changing a source or an excluded-load adjustment keeps +every learned day, and a timezone change re-bins them by the offset between the +two zones; Recorder backfill then rebuilds whatever is still missing in the +background. A gap longer than five minutes is not interpolated. ### The solar profile remains immature or falls back diff --git a/tests/test_charge_delay.py b/tests/test_charge_delay.py index 02f70baf..b59d43bf 100644 --- a/tests/test_charge_delay.py +++ b/tests/test_charge_delay.py @@ -809,7 +809,8 @@ def _at_hour(monkeypatch, hour): def _cushion_mgr(forecast): """Gate sitting in the cushion band at 09:00. - needed = (80-50)% x 10 kWh = 3.0 kWh, factored (x1.3) = 3.9 kWh. + needed = (80-50)% x 10 kWh = 3.0 kWh (battery-side), AC-equivalent + (÷ CHARGE_EFFICIENCY=0.85) = 3.53 kWh, factored (x1.3) = 4.59 kWh. remaining_consumption = 5.0 x 7/24 = 1.46 kWh. """ ctrl = _controller( @@ -821,10 +822,11 @@ def _cushion_mgr(forecast): def test_cushion_shortfall_holds_for_cheaper_hour(monkeypatch): - # net = 3.39: below the factored 3.9 edge but still above the bare 3.0 need - # -> hold for the cheaper hour instead of unlocking into the morning peak. + # net = 4.04: below the factored 4.59 edge but still above the bare + # (AC-equivalent) 3.53 need -> hold for the cheaper hour instead of + # unlocking into the morning peak. _at_hour(monkeypatch, 9) - mgr = _cushion_mgr(4.85) + mgr = _cushion_mgr(5.5) mgr._price_optimal_release_h = lambda now_h, edge_h, charge_h=None: 12.0 assert mgr._should_delay_charge(80) is True assert mgr._controller._charge_delay_status["estimated_unlock_time"] == "12:00" @@ -833,7 +835,7 @@ def test_cushion_shortfall_holds_for_cheaper_hour(monkeypatch): def test_cushion_shortfall_unlocks_when_now_is_cheapest(monkeypatch): _at_hour(monkeypatch, 9) - mgr = _cushion_mgr(4.85) + mgr = _cushion_mgr(5.5) mgr._price_optimal_release_h = lambda now_h, edge_h, charge_h=None: now_h assert mgr._should_delay_charge(80) is False assert mgr._controller._charge_delay_status["unlock_reason"] == "energy_balance" @@ -842,15 +844,15 @@ def test_cushion_shortfall_unlocks_when_now_is_cheapest(monkeypatch): def test_cushion_shortfall_unlocks_without_price_data(monkeypatch): # No price data -> legacy instant unlock preserved. _at_hour(monkeypatch, 9) - mgr = _cushion_mgr(4.85) + mgr = _cushion_mgr(5.5) mgr._price_optimal_release_h = lambda now_h, edge_h, charge_h=None: None assert mgr._should_delay_charge(80) is False assert mgr._controller._charge_delay_status["unlock_reason"] == "energy_balance" def test_genuine_deficit_unlocks_without_consulting_prices(monkeypatch): - # net = 1.09 < needed 3.0: a real deficit, grid charging may be - # required -> unlock immediately, prices must not be able to hold it. + # net = 1.54 < AC-equivalent needed 3.53: a real deficit, grid charging + # may be required -> unlock immediately, prices must not be able to hold it. _at_hour(monkeypatch, 9) mgr = _cushion_mgr(3.0) called = [] @@ -861,10 +863,14 @@ def test_genuine_deficit_unlocks_without_consulting_prices(monkeypatch): def test_cushion_hold_window_never_passes_bare_balance_edge(monkeypatch): - # The edge handed to the price scorer is the BARE (x1.0) balance crossing, - # never the factored one, so the hold cannot eat into the target itself. + # The edge handed to the price scorer is bounded by every bare (x1.0) + # balance crossing in play — both the whole-day one (cushion_edge_h) and + # the deadline-bounded one (solar_feasible_unlock_h, added so the delay + # unlocks early enough for solar-only charging to still make the safety + # margin) — never the factored one, so the hold cannot eat into the + # target itself. _at_hour(monkeypatch, 9) - mgr = _cushion_mgr(4.85) + mgr = _cushion_mgr(5.5) edges = [] mgr._price_optimal_release_h = lambda now_h, edge_h, charge_h=None: edges.append(edge_h) or now_h # Record what the gate actually asked the projection for, so the expected edge @@ -879,10 +885,22 @@ def _spy(*args, **kwargs): mgr._should_delay_charge(80) bare = [p for p in projections if p[1].get("safety_factor") == 1.0] - assert len(bare) == 1, "the bare-balance projection must be requested exactly once" - bare_edge = bare[0][2] - time_backup_h = 16.0 - mgr._controller._charge_delay_status["charge_time_h"] - 0.5 - assert edges == [pytest.approx(min(bare_edge, time_backup_h))] + assert len(bare) == 2, "expected the deadline-bounded and whole-day bare projections" + deadline_bounded = [p for p in bare if "horizon_h" in p[1]] + whole_day = [p for p in bare if "horizon_h" not in p[1]] + assert len(deadline_bounded) == 1 and len(whole_day) == 1 + solar_feasible_edge = deadline_bounded[0][2] + cushion_edge = whole_day[0][2] + nominal_time_backup_h = 16.0 - mgr._controller._charge_delay_status["charge_time_h"] - 0.5 + time_backup_h = ( + min(nominal_time_backup_h, solar_feasible_edge) + if solar_feasible_edge is not None + else nominal_time_backup_h + ) + expected_edge = ( + min(time_backup_h, cushion_edge) if cushion_edge is not None else time_backup_h + ) + assert edges == [pytest.approx(expected_edge)] assert edges[0] > 9.0 # there was room to wait at all @@ -894,3 +912,99 @@ def test_estimate_bare_edge_is_later_than_factored_edge(): factored = mgr._estimate_energy_balance_unlock_h(10.0, 1.0, 8.0, 16.0, 8.0) bare = mgr._estimate_energy_balance_unlock_h(10.0, 1.0, 8.0, 16.0, 8.0, safety_factor=1.0) assert bare > factored + + +# ---------------------------------------------------------------------- +# Deadline-bounded solar feasibility (time_backup should reflect realistic +# solar-only pacing, not just a full-hardware-power assumption) +# ---------------------------------------------------------------------- + +def test_horizon_h_shrinks_the_window_without_reshaping_the_curve(): + """A tighter horizon must only cut off late-day production, not distort + the sinusoidal shape (which stays keyed to the true t_start/t_end). + """ + mgr = _make_mgr(_controller()) + full = mgr._estimate_energy_balance_unlock_h( + 10.0, 5.0, 8.0, 16.0, 8.0, safety_factor=1.0, + ) + bounded = mgr._estimate_energy_balance_unlock_h( + 10.0, 5.0, 8.0, 16.0, 8.0, safety_factor=1.0, horizon_h=12.0, + ) + # Less production is countable within the shorter window, so the + # bounded edge (if any) must be no later than the full-window edge, and + # a bounded run can turn a previously-safe (None) day into a real edge. + assert bounded is not None + assert full is None or bounded <= full + 1e-9 + + +def test_horizon_h_matching_t_end_reproduces_original_behavior(): + mgr = _make_mgr(_controller()) + args = (10.0, 5.0, 8.0, 16.0, 8.0) + kwargs = dict(safety_factor=1.0, forecast_is_remaining=True) + baseline = mgr._estimate_energy_balance_unlock_h(*args, **kwargs) + explicit = mgr._estimate_energy_balance_unlock_h(*args, horizon_h=16.0, **kwargs) + assert explicit == baseline + + +def test_solar_feasible_unlock_pulls_time_backup_earlier_than_full_power_assumption(monkeypatch): + """Plenty of total solar kWh by t_end, but not enough arrives before the + safety-margin deadline specifically -> the delay must unlock earlier + than the naive full-hardware-power formula would, instead of only + discovering the shortfall at the deadline itself and needing a grid + top-up. + """ + now = dt_util.now().replace(hour=9, minute=0, second=0, microsecond=0) + monkeypatch.setattr(charge_delay_module, "_decision_now", lambda: now) + ctrl = _controller( + # 30% -> 80% of 10 kWh = 5 kWh needed; tiny system power so the + # nominal (full-power) time-backup estimate is very late in the day. + coordinators=[_coord(soc=30, total_energy=10.0, min_soc=20)], + _consumption_tracker=_tracker( + estimate_t_end=lambda: 18.0, + get_avg_daily_consumption=lambda: 0.5, + ), + _solar_t_start=8.0, + _delay_safety_margin_h=1.0, + _effective_system_capacity=lambda coords, is_charging: 6000.0, + ) + # Big whole-day forecast (so energy_insufficient is False over t_end) but + # most of it is modelled as arriving in a bell curve peaking mid-day — + # by the deadline (17:00) less may have arrived than the bare 5 kWh need. + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(5.5)}) + + mgr._should_delay_charge(80) + status = ctrl._charge_delay_status + + nominal = 18.0 - status["charge_time_h"] - 1.0 + # The gate must have tightened the estimate below the naive full-power + # figure once a deadline-bounded solar shortfall is projected. + unlock_h = _hhmm_to_h(status["estimated_unlock_time"]) + assert unlock_h <= nominal + 1e-6 + + +def _hhmm_to_h(value): + h, m = value.split(":") + return int(h) + int(m) / 60.0 + + + +def test_energy_balance_gate_accounts_for_charge_efficiency(monkeypatch): + """A forecast that exactly matches the raw battery-side deficit must NOT + be treated as sufficient — converting it into stored energy always loses + (1 - CHARGE_EFFICIENCY) along the way, so the AC-side solar requirement + must be larger than the plain kWh gap by 1/CHARGE_EFFICIENCY. + """ + _at_hour(monkeypatch, 9) + # needed = (80-50)% * 10 kWh = 3.0 kWh (battery-side). + ctrl = _controller( + coordinators=[_coord(soc=50, total_energy=10.0, min_soc=20)], + _consumption_tracker=_tracker(get_avg_daily_consumption=lambda: 0.0), + _solar_t_start=8.0, + ) + # Forecast exactly equal to the bare battery-side need (no consumption + # subtracted) -> without the efficiency fix this reads as "just enough"; + # with it, it must read as a genuine deficit (3.0 < 3.0 / 0.85). + mgr = _make_mgr(ctrl, states={"sensor.forecast": _state(3.0)}) + + assert mgr._should_delay_charge(80) is False + assert mgr._controller._charge_delay_status["unlock_reason"] == "energy_balance" diff --git a/tests/test_consumption_profile.py b/tests/test_consumption_profile.py index ddd1c193..df7cb71a 100644 --- a/tests/test_consumption_profile.py +++ b/tests/test_consumption_profile.py @@ -21,6 +21,7 @@ _series_to_bins, adjust_remaining_fallback_energy, fallback_daily_intervals, + rebin_days_to_timezone, split_sample_across_bins, ) @@ -655,3 +656,81 @@ def test_range_query_prorates_partial_interval_and_masks_slot_days(): # The 16 minutes are all in one nominal 1 kWh/hour profile, but the middle # 8 minutes are a configured Monday charging window. assert result.energy_kwh == pytest.approx(8 / 15, abs=1e-6) + + +def test_a_source_change_keeps_the_learned_days(): + """No integration setting may erase learned capture (only a timezone move).""" + today = date.today() + profile = _profile({today: _day(today, 1.0)}) + profile._config_entry = SimpleNamespace( + entry_id="e1", data={"consumption_sensor": "sensor.grid"} + ) + profile._backfill_generation = 1 + profile._backfill_task = None + profile._backfill_status = "complete" + profile._active_fingerprint = profile.configuration_fingerprint() + + assert profile.invalidate_if_configuration_changed() is False + + profile._config_entry.data = {"consumption_sensor": "sensor.other_grid"} + + assert profile.invalidate_if_configuration_changed() is True + assert list(profile._days) == [today] + # Continuity is broken so the next sample is a baseline, not a trapezoid + # spanning both derivations. + assert profile._last_sample_time is None + + +def test_a_timezone_change_moves_the_bins_instead_of_dropping_them(): + """Madrid 20:00 is Lisbon 19:00: the same energy, one hour earlier.""" + day = ProfileDay(date(2026, 3, 3)) + day.energy_kwh[80] = 0.5 # 20:00-20:15 local + day.coverage_s[80] = INTERVAL_SECONDS + day.complete = True + + rebinned = rebin_days_to_timezone( + {day.local_date: day}, MADRID, ZoneInfo("Europe/Lisbon") + ) + + moved = rebinned[date(2026, 3, 3)] + assert moved.energy_kwh[76] == pytest.approx(0.5) # 19:00-19:15 local + assert moved.coverage_s[76] == pytest.approx(INTERVAL_SECONDS) + assert moved.energy_kwh[80] == 0.0 + # A day rebuilt from partial hours stays incomplete so backfill re-fetches it. + assert moved.complete is False + + +def test_rebinning_carries_energy_across_the_local_date_boundary(): + """Madrid 00:00 is the previous day 23:00 in Lisbon.""" + day = ProfileDay(date(2026, 3, 3)) + day.energy_kwh[0] = 0.25 + day.coverage_s[0] = INTERVAL_SECONDS + + rebinned = rebin_days_to_timezone( + {day.local_date: day}, MADRID, ZoneInfo("Europe/Lisbon") + ) + + assert set(rebinned) == {date(2026, 3, 2)} + assert rebinned[date(2026, 3, 2)].energy_kwh[92] == pytest.approx(0.25) + + +def test_one_week_of_day_type_samples_matures_without_a_weekday_pair(): + # Seven consecutive days ending Wednesday 2026-09-02, forecasting Thursday + # 2026-09-03: only one Thursday (Aug 27) exists, so the same-weekday pair is + # arithmetically impossible. The weekday-type samples must carry the day. + days = { + date(2026, 8, 27) + timedelta(days=offset): _day( + date(2026, 8, 27) + timedelta(days=offset), 0.25 + ) + for offset in range(7) + } + + profile = _profile(days) + profile._today = lambda: date(2026, 9, 3) + forecast = profile.forecast_for_date(date(2026, 9, 3)) + + assert forecast.weekday_samples == 1 # the single Thursday, below the pair + assert forecast.mature is True + assert forecast.source == "profile" + assert forecast.fallback_reason is None + assert forecast.energy_kwh == pytest.approx(0.25 * INTERVAL_COUNT) diff --git a/tests/test_consumption_tracker.py b/tests/test_consumption_tracker.py index 18daf89c..3f2b07b9 100644 --- a/tests/test_consumption_tracker.py +++ b/tests/test_consumption_tracker.py @@ -698,3 +698,30 @@ def test_derive_home_applies_inverted_meter_during_export(): {"sensor.grid": _w(-500)}, [], meter_inverted=True, ) assert tracker._derive_home_power_kw() == pytest.approx(0.5) + + +@pytest.mark.asyncio +async def test_daily_capture_runs_while_predictive_charging_is_off(): + """Learning must not be gated on the feature that consumes it. + + The home-energy accumulator is integrated on every control cycle whatever + the switch says, so refusing to snapshot it left the seven-day history full + of DEFAULT_BASE_CONSUMPTION_KWH sentinels for anyone who enabled predictive + charging later. + """ + tracker = ConsumptionTracker.__new__(ConsumptionTracker) + tracker._controller = SimpleNamespace( + predictive_charging_enabled=False, + _household_energy_accumulator=6.42, + _daily_consumption_history=[], + ) + tracker._period_intersects = lambda *_args: False + saved = [] + async def _save(): + saved.append(True) + tracker.save_consumption_history = _save + + await tracker.capture_daily_consumption() + + assert tracker._controller._daily_consumption_history == [(date.today(), 6.42)] + assert saved diff --git a/tests/test_daily_operation_manager.py b/tests/test_daily_operation_manager.py index 6640cdf6..c18fd558 100644 --- a/tests/test_daily_operation_manager.py +++ b/tests/test_daily_operation_manager.py @@ -340,6 +340,24 @@ def test_runtime_diary_omits_delay_and_setpoint_context_on_weekly_full_charge_da assert decision["delay_until"] is None +def test_runtime_diary_omits_setpoint_context_when_charge_delay_is_off(): + coordinator = SimpleNamespace( + capabilities=SimpleNamespace(has_mppt_pv=False, has_solar_telemetry=False), + data={"battery_power": 1000}, + ) + controller = _runtime_controller([coordinator]) + controller.charge_delay_enabled = False + controller._delay_soc_setpoint_enabled = True + controller._delay_setpoint_reached = False + controller._balance_monitor_overrides_delay = lambda: False + + decision = ChargeDischargeController._daily_operation_runtime_decision( + controller, datetime(2026, 8, 24, 10, 0, tzinfo=MADRID) + ) + + assert not decision["context_mask"] & CONTEXT_SETPOINT + + def test_runtime_delay_boundary_is_removed_when_delay_stops(): clock = MutableClock(datetime(2026, 8, 24, 10, 0, tzinfo=MADRID)) manager = _manager(clock, mode="normal") diff --git a/tests/test_excluded_device_config_flow.py b/tests/test_excluded_device_config_flow.py index d7fe8f53..020ac2f0 100644 --- a/tests/test_excluded_device_config_flow.py +++ b/tests/test_excluded_device_config_flow.py @@ -6,6 +6,9 @@ MarstekVenusConfigFlow, OptionsFlowHandler, ) +from custom_components.omnibattery.tracking.consumption_profile import ( + ConsumptionProfileTracker, +) def _schema_defaults(result) -> dict[str, object]: @@ -290,3 +293,48 @@ async def test_options_flow_keeps_runtime_fields_when_power_sensor_is_added(): assert flow.excluded_devices[0]["activity_sensor"] == "binary_sensor.ev_charging" assert flow.excluded_devices[0]["enabled"] is False assert flow.excluded_devices[0]["exclusion_pct"] == 60 + + +def _profile_fingerprint(entry: SimpleNamespace) -> str: + """Fingerprint the 28-day consumption profile reports source changes with.""" + profile = ConsumptionProfileTracker.__new__(ConsumptionProfileTracker) + profile._config_entry = entry + profile._hass = SimpleNamespace(config=SimpleNamespace(time_zone="Europe/Madrid")) + return profile.configuration_fingerprint() + + +async def test_resaving_a_device_unchanged_is_not_a_source_change(): + """A no-op pass through the options flow must not read as a new source.""" + entry = SimpleNamespace( + entry_id="profile-entry", + data={ + "consumption_sensor": "sensor.grid", + "excluded_devices": [ + { + "power_sensor": "sensor.wallbox_power", + "included_in_consumption": True, + "enabled": False, + "exclusion_pct": 60, + } + ], + }, + ) + before = _profile_fingerprint(entry) + + flow = _options_flow(entry) + form = await flow.async_step_add_excluded_device() + await flow.async_step_add_excluded_device(_schema_defaults(form)) + + resaved = SimpleNamespace( + entry_id=entry.entry_id, + data={**entry.data, "excluded_devices": flow.excluded_devices}, + ) + assert _profile_fingerprint(resaved) == before + + changed = SimpleNamespace( + entry_id=entry.entry_id, + data={**entry.data, "excluded_devices": [ + {**flow.excluded_devices[0], "exclusion_pct": 40} + ]}, + ) + assert _profile_fingerprint(changed) != before diff --git a/tests/test_frontend_daily_operation.py b/tests/test_frontend_daily_operation.py index d845ad80..b46734d0 100644 --- a/tests/test_frontend_daily_operation.py +++ b/tests/test_frontend_daily_operation.py @@ -43,12 +43,27 @@ def test_disabled_hourly_balance_suppresses_feature_legend_and_markers(): assert "hourlyBalance: snapshot.hourlyBalanceEnabled" in panel -def test_open_cell_below_a_minute_of_coverage_is_omitted_not_plotted_raw(): +def test_open_cell_is_completed_from_the_previous_quarter_never_dropped(): + """No hole and no spike at the now marker: the open cell is always plotted. + + Dropping it below a minute of coverage left a 30-minute gap straddling the + marker, and scaling it by 900 / seconds collapsed the point on a young + quarter, so the forecast hand-off looked like a spike. + """ panel = PANEL.read_text(encoding="utf-8") - assert "if (value != null && seconds != null && seconds < 900) {" in panel - assert "plotted[index] = seconds >= 60 ? value * 900 / seconds : null;" in panel - assert "seconds >= 60 && seconds < 900" not in panel + assert ( + "if (seconds != null && seconds < 900 && (value != null || previous != null)) {" + in panel + ) + assert ( + "plotted[index] = (value ?? 0) + (previous ?? 0) * (900 - seconds) / 900;" + in panel + ) + assert "const previous = this._dailyOperationValueAt(plotted, index - 1);" in panel + # The two shapes that produced the artefacts must not come back. + assert "plotted[index] = seconds >= 60" not in panel + assert "* 900 / seconds;" not in panel def test_forecast_handoff_drops_the_open_cell_when_there_is_no_observed_point(): diff --git a/tests/test_marstek_driver.py b/tests/test_marstek_driver.py index e93ab021..0d49667a 100644 --- a/tests/test_marstek_driver.py +++ b/tests/test_marstek_driver.py @@ -108,6 +108,21 @@ def test_capability_envelope_uses_hardware_ceiling_not_user_limit(): assert drv.capabilities.max_discharge_power_w == 2500 +@pytest.mark.parametrize("version", ["v2", "v3", "vA", "vD"]) +def test_no_hardware_power_floor(version): + """min_*_power_w must come from the setpoint registers (min 0), not from + max_charge_power's min (800 W on v2/v3). The latter is only the lowest + ceiling the user may configure; reading it as a floor clamped predictive + charging to 800 W of grid import.""" + drv = MarstekModbusDriver( + "1.2.3.4", 502, version, + client=_fake_client(), # definitions=None -> real defs load + **({"ems_version": 149} if version == "vD" else {}), + ) + assert drv.capabilities.min_charge_power_w == 0 + assert drv.capabilities.min_discharge_power_w == 0 + + @pytest.mark.parametrize("ems_version,expected", [ (None, 2200), (147, 2200), diff --git a/tests/test_solar_profile.py b/tests/test_solar_profile.py index eb6a4346..1fed88af 100644 --- a/tests/test_solar_profile.py +++ b/tests/test_solar_profile.py @@ -276,3 +276,108 @@ def test_invalid_direct_power_does_not_become_a_zero_sample(): ) assert tracker._read_power_kw("sensor.pv") is None + + +def test_day_reports_name_the_reason_each_day_was_rejected(): + profile = _profile() + today = date.today() + valid_day = today - timedelta(days=1) + windowless = today - timedelta(days=2) + holed = today - timedelta(days=3) + profile._days = { + valid_day: _full_day(valid_day), + windowless: _full_day(windowless), + holed: _full_day(holed), + } + # A day whose solar window was never established still holds raw energy. + profile._days[windowless].solar_start = None + profile._days[windowless].solar_end = None + # A day sampled with gaps keeps its window but loses coverage inside it. + for index in range(32, 64): + profile._days[holed].coverage_s[index] = 0.0 + profile._days[holed].energy_kwh[index] = 0.0 + + reports = {report["date"]: report for report in profile._day_reports()} + + assert reports[valid_day.isoformat()]["valid"] is True + assert reports[valid_day.isoformat()]["reason"] is None + assert reports[windowless.isoformat()]["reason"] == "solar_window_invalid" + assert reports[holed.isoformat()]["reason"] == "insufficient_coverage" + assert reports[holed.isoformat()]["coverage_ratio"] < 0.90 + + +def test_stale_scale_days_cannot_veto_the_current_scale(): + """A batch of days recorded at a wrong scale must age out of the reference. + + Regression for a profile that recorded ~60x inflated day totals until a + fix landed: the inflated days kept the relative-energy median so high that + every correct day after them was rejected, and the profile could never + recover on its own. + """ + profile = _profile() + today = date.today() + profile._days = {} + for offset in range(8, 18): # Old regime: same shape, 60x the energy. + local_date = today - timedelta(days=offset) + day = _full_day(local_date) + day.energy_kwh = [value * 60.0 for value in day.energy_kwh] + profile._days[local_date] = day + for offset in range(1, 8): # Current regime: correct energy. + local_date = today - timedelta(days=offset) + profile._days[local_date] = _full_day(local_date) + + reports = {report["date"]: report for report in profile._day_reports()} + current = [ + reports[(today - timedelta(days=offset)).isoformat()] for offset in range(1, 8) + ] + + assert all(report["valid"] for report in current), [ + (report["date"], report["reason"]) for report in current if not report["valid"] + ] + assert profile.learn_shape(today).mature is True + + +def test_regime_change_carries_the_whole_matching_run_and_stays_mature(): + """The new generation must keep the days that already match the new scale. + + Moving only the three confirming days -- and flagging all of them as a + transition, which excludes their bins -- left the new generation with no + shape at all, so a profile that was mature went back to the sinusoidal + fallback for a week after every regime change. + """ + profile = _profile() + profile.request_save = lambda: None + reference = date.today() + profile._days = {} + for offset in range(1, 25): + local_date = reference - timedelta(days=offset) + day = _full_day(local_date) + if offset >= 8: # Old regime: same shape, 60x the energy. + day.energy_kwh = [value * 60.0 for value in day.energy_kwh] + profile._days[local_date] = day + assert profile.learn_shape(reference).mature is True + + assert profile.detect_capacity_regime(reference) is True + + assert profile.generation == 2 + carried = [reference - timedelta(days=offset) for offset in range(1, 8)] + assert all(profile._days[local_date].generation == 2 for local_date in carried) + assert all( + profile._days[reference - timedelta(days=offset)].generation == 1 + for offset in range(8, 25) + ) + # Only the oldest day of the run straddles the change. + flagged = [ + local_date + for local_date in carried + if any( + flags & int(SolarQualityFlag.CONFIGURATION_TRANSITION) + for flags in profile._days[local_date].quality_flags + ) + ] + assert flagged == [reference - timedelta(days=7)] + # The six unflagged days still describe a shape, so no maturity gap opens. + snapshot = profile.learn_shape(reference) + assert snapshot.mature is True + assert snapshot.eligible_days == 7 + assert all(count == 6 for count in snapshot.bin_contributions) diff --git a/tests/test_venus_ad_pack_soc.py b/tests/test_venus_ad_pack_soc.py new file mode 100644 index 00000000..5aea552f --- /dev/null +++ b/tests/test_venus_ad_pack_soc.py @@ -0,0 +1,325 @@ +"""Per-pack SOC on Venus A/D (issue #350). + +A Venus A/D couples several battery packs and fills them in sequence, so the +aggregate SOC at 32104 can read 100% while a later pack is still empty. Each +pack publishes its own SOC on a stride-100 layout (34002, 34102, ...). + +Two things are pinned here: + +* the start-up probe that learns which of the six slots exist — an absent slot + may either fail to answer or read a flat 0, and the probe must be right under + both without knowing which the firmware does; +* the *additional* discharge floor: a pack at min_soc blocks discharge even + when the aggregate is still above it. The symmetric swap (max(pack_soc) + replacing the aggregate, which would extend discharge) is deliberately not + implemented — see the plan's F3. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from custom_components.omnibattery import ChargeDischargeController +from custom_components.omnibattery.const import PACK_SOC_KEYS +from custom_components.omnibattery.drivers import MarstekModbusDriver +from custom_components.omnibattery.drivers.marstek import _PACK_PROBE_CYCLES + +from custom_components.omnibattery.control.weekly_full_charge import ( + WeeklyFullChargeManager, + _BMS_CUTOFF_REQUIRED_CYCLES, +) + +from tests.conftest import FakeCoordinator + + +# --- driver: pack discovery ------------------------------------------------- + + +def _driver(reads: dict[int, int | None], *, version="vD"): + """Driver over the real vD definitions, answering from a register map.""" + client = AsyncMock() + client.async_write_register = AsyncMock(return_value=True) + client.async_read_block = AsyncMock(return_value=None) + + async def _read(register, data_type="uint16", count=None, sensor_key=None): + return reads.get(register) + + client.async_read_register = AsyncMock(side_effect=_read) + return MarstekModbusDriver( + "1.2.3.4", 502, version, client=client, ems_version=149, + ) + + +def _pack_register(n: int) -> int: + return 34002 + 100 * (n - 1) + + +async def _probe(driver, cycles=_PACK_PROBE_CYCLES): + """Run the coordinator's poll shape: aggregate first, then one call per pack.""" + for _ in range(cycles): + await driver.read_telemetry(["battery_soc"]) + for key in PACK_SOC_KEYS: + await driver.read_telemetry([key]) + + +@pytest.mark.asyncio +async def test_absent_slots_that_fail_to_answer_are_dropped(): + # Variant 1 of the unknown: an empty slot raises a Modbus exception, so the + # driver omits its key from the snapshot. + reads = {32104: 80, _pack_register(1): 812, _pack_register(2): 795} + driver = _driver(reads) + await _probe(driver) + + assert driver._packs == {"battery_soc_pack_1", "battery_soc_pack_2"} + keys = {k for g in driver.read_groups for k in g.keys} + assert keys.intersection(PACK_SOC_KEYS) == driver._packs + present = {d["key"] for d in driver.sensor_definitions} + assert present.intersection(PACK_SOC_KEYS) == driver._packs + + +@pytest.mark.asyncio +async def test_absent_slots_that_answer_zero_are_dropped(): + # Variant 2: an empty slot answers 0. Only the aggregate separates that from + # a pack that is genuinely flat, hence the "0 and aggregate > 5%" rule. + reads = {32104: 80, _pack_register(1): 812, _pack_register(2): 795} + reads.update({_pack_register(n): 0 for n in (3, 4, 5, 6)}) + driver = _driver(reads) + await _probe(driver) + + assert driver._packs == {"battery_soc_pack_1", "battery_soc_pack_2"} + + +@pytest.mark.asyncio +async def test_flat_pack_on_an_empty_battery_is_kept(): + # The trap the plan calls out: discarding every slot that reads 0 loses a + # pack that is really at 0%. With the aggregate down at 3% a 0 is real. + reads = {32104: 3, _pack_register(1): 0, _pack_register(2): 0} + driver = _driver(reads) + await _probe(driver) + + assert driver._packs == {"battery_soc_pack_1", "battery_soc_pack_2"} + + +@pytest.mark.asyncio +async def test_one_transient_failure_does_not_hide_a_real_pack(): + reads = {32104: 80, _pack_register(1): 812, _pack_register(2): 795} + driver = _driver(reads) + + del reads[_pack_register(2)] # pack 2 misses the first cycle only + await _probe(driver, cycles=1) + reads[_pack_register(2)] = 795 + await _probe(driver, cycles=_PACK_PROBE_CYCLES - 1) + + assert "battery_soc_pack_2" in driver._packs + + +@pytest.mark.asyncio +async def test_pack_keys_poll_while_probing_then_only_the_found_ones(): + # They ship disabled by default, so without a dependency claim the + # coordinator would skip them and the probe could never answer. + reads = {32104: 80, _pack_register(1): 812} + driver = _driver(reads) + assert driver.control_dependency_keys.issuperset(PACK_SOC_KEYS) + + await _probe(driver) + assert driver.control_dependency_keys.intersection(PACK_SOC_KEYS) == { + "battery_soc_pack_1" + } + + +@pytest.mark.asyncio +async def test_v3_has_no_pack_sensors(): + driver = _driver({37005: 80}, version="v3") + assert driver.control_dependency_keys.isdisjoint(PACK_SOC_KEYS) + assert not {d["key"] for d in driver.sensor_definitions}.intersection(PACK_SOC_KEYS) + + +# --- control: pack-aware charge ceiling and discharge floor ----------------- +# +# The verdicts are asymmetric because the packs fill in sequence: +# full <=> the LEAST full pack reached the ceiling -> min(pack_soc) +# empty <=> the FULLEST pack reached the floor -> max(pack_soc) +# Neither may fire on the first pack to get there, which is the #350 bug and its +# mirror image. + + +def _discharge_blocks(data, *, min_soc=10): + """Run _refresh_battery_discharge_limit_blocks and report what it set.""" + coordinator = FakeCoordinator(min_soc=min_soc, data=data) + set_blocks = [] + ctrl = SimpleNamespace( + coordinators=[coordinator], + _effective_discharge_min_soc=lambda c: (min_soc, "min_soc"), + set_discharge_block=lambda *a, **kw: set_blocks.append((a, kw)), + remove_discharge_block=lambda *a, **kw: None, + ) + ChargeDischargeController._refresh_battery_discharge_limit_blocks(ctrl) + return set_blocks + + +def _dischargeable(data, *, min_soc=10): + coordinator = FakeCoordinator(min_soc=min_soc, data=data) + ctrl = SimpleNamespace( + coordinators=[coordinator], + _non_responsive=SimpleNamespace(is_excluded=lambda c: False), + _is_backup_function_active=lambda c: False, + _is_manual_slot_owned=lambda c: False, + is_discharge_blocked=lambda c: False, + ) + got = ChargeDischargeController._get_available_batteries(ctrl, is_charging=False) + return got == [coordinator] + + +def _chargeable(data, *, max_soc=100): + coordinator = FakeCoordinator(max_soc=max_soc, data=data) + coordinator.enable_charge_hysteresis = False + ctrl = SimpleNamespace( + coordinators=[coordinator], + _non_responsive=SimpleNamespace(is_excluded=lambda c: False), + _is_backup_function_active=lambda c: False, + _is_manual_slot_owned=lambda c: False, + is_charge_blocked=lambda c: False, + get_charge_blockers=lambda c: {}, + _weekly_full_charge_unlocked=lambda: False, + _effective_charge_max_soc=lambda c, w: (max_soc, "max_soc"), + _should_charge_to_bms_cutoff=lambda c, m: False, + _normal_balance_recal_override={}, + _weekly_charge_mgr=SimpleNamespace(is_battery_full=lambda c: False), + _predictive_charge_target_soc=None, + ) + got = ChargeDischargeController._get_available_batteries(ctrl, is_charging=True) + return got == [coordinator] + + +# --- discharge: the fullest pack decides ------------------------------------ + + +def test_a_pack_at_the_floor_does_not_stop_the_others(): + # The mirror of #350: the first pack to empty must not end the discharge + # while another still holds charge. + data = {"battery_soc": 30, "battery_soc_pack_1": 50.0, "battery_soc_pack_2": 10.0} + assert not _discharge_blocks(data) + assert _dischargeable(data) + + +def test_discharge_stops_when_the_fullest_pack_reaches_the_floor(): + data = {"battery_soc": 30, "battery_soc_pack_1": 10.0, "battery_soc_pack_2": 8.0} + assert _discharge_blocks(data) + assert not _dischargeable(data) + + +def test_packs_override_an_aggregate_already_at_the_floor(): + # The aggregate is at the floor but a pack still has 40% to give: keep going. + data = {"battery_soc": 10, "battery_soc_pack_1": 40.0, "battery_soc_pack_2": 12.0} + assert not _discharge_blocks(data) + assert _dischargeable(data) + + +def test_out_of_range_pack_reading_is_ignored(): + # The addresses come from a third-party register map, so a garbage value + # must not be able to move the floor in either direction. + assert _discharge_blocks({"battery_soc": 9, "battery_soc_pack_1": 6553.5}) + + +def test_no_pack_telemetry_behaves_exactly_as_before(): + assert not _discharge_blocks({"battery_soc": 30}) + assert _discharge_blocks({"battery_soc": 9}) + assert _dischargeable({"battery_soc": 30}) + assert not _dischargeable({"battery_soc": 9}) + + +# --- charge: the least full pack decides ------------------------------------ + + +def test_a_full_first_pack_does_not_end_the_charge(): + # #350 itself: the aggregate reads 100% as soon as the first coupled pack + # fills, while later packs are still empty. + assert _chargeable({"battery_soc": 100, "battery_soc_pack_1": 100.0, + "battery_soc_pack_2": 40.0}) + + +def test_charge_stops_when_the_least_full_pack_reaches_the_ceiling(): + assert not _chargeable({"battery_soc": 100, "battery_soc_pack_1": 100.0, + "battery_soc_pack_2": 100.0}) + + +def test_charge_respects_a_lower_ceiling_on_the_least_full_pack(): + assert not _chargeable({"battery_soc": 70, "battery_soc_pack_1": 90.0, + "battery_soc_pack_2": 80.0}, max_soc=80) + assert _chargeable({"battery_soc": 70, "battery_soc_pack_1": 90.0, + "battery_soc_pack_2": 79.0}, max_soc=80) + + +def test_charge_without_pack_telemetry_is_unchanged(): + assert _chargeable({"battery_soc": 99}) + assert not _chargeable({"battery_soc": 100}) + + +# --- charge: the BMS-cutoff arming gate (#350's actual 27 Aug trace) --------- + + +class _PackCoord: + """Coordinator stand-in for the weekly manager (counter is name-keyed).""" + + def __init__(self, **data): + self.name = "bat" + self.battery_version = "vD" + self.brand = None + self.commanded_charge_power = 200 + self.data = {"battery_power": 0, "inverter_state": 1, **data} + + +def _weekly(coord): + ctrl = SimpleNamespace(coordinators=[coord], weekly_full_charge_enabled=True) + m = WeeklyFullChargeManager.__new__(WeeklyFullChargeManager) + m._controller = ctrl + m._bms_cutoff_counts = {} + m._already_complete_logged = False + m.is_active = lambda: True + return m + + +def test_taper_zone_does_not_arm_while_a_pack_is_still_filling(): + # Literal reproduction of the 27 Aug trace in #350: the top cell sits at + # 3.481 V because an earlier pack finished hours ago, while the least full + # pack is at 89.9%. A lull in acceptance must not be read as a cutoff. + coord = _PackCoord( + battery_soc=94, max_cell_voltage=3.481, + battery_soc_pack_1=100.0, battery_soc_pack_2=89.9, + ) + weekly = _weekly(coord) + for _ in range(_BMS_CUTOFF_REQUIRED_CYCLES * 3): + weekly.tick_bms_cutoff() + assert weekly._bms_cutoff_counts.get("bat", 0) == 0 + assert weekly.is_battery_full(coord) is False + + +def test_taper_zone_still_arms_when_every_pack_is_at_the_top(): + coord = _PackCoord( + battery_soc=94, max_cell_voltage=3.481, + battery_soc_pack_1=100.0, battery_soc_pack_2=99.4, + ) + weekly = _weekly(coord) + for _ in range(_BMS_CUTOFF_REQUIRED_CYCLES): + weekly.tick_bms_cutoff() + assert weekly._bms_cutoff_counts["bat"] >= _BMS_CUTOFF_REQUIRED_CYCLES + + +def test_taper_zone_arming_unchanged_without_pack_telemetry(): + coord = _PackCoord(battery_soc=94, max_cell_voltage=3.481) + weekly = _weekly(coord) + for _ in range(_BMS_CUTOFF_REQUIRED_CYCLES): + weekly.tick_bms_cutoff() + assert weekly._bms_cutoff_counts["bat"] >= _BMS_CUTOFF_REQUIRED_CYCLES + + +def test_full_verdict_waits_for_the_least_full_pack(): + coord = _PackCoord( + battery_soc=100, max_cell_voltage=3.40, + battery_soc_pack_1=100.0, battery_soc_pack_2=62.0, + ) + assert _weekly(coord).is_battery_full(coord) is False + coord.data["battery_soc_pack_2"] = 100.0 + assert _weekly(coord).is_battery_full(coord) is True