Skip to content

Commit c000b53

Browse files
chalfontchubbyclaudeCI
authored
Predbat-led EV charger control: shared mixin, Octopus Intelligent rule and guest charging for GE, Ohme and Zappi (folds #5284 + #5311) (#5448)
* feat(car): add a shared Predbat-led EV charger control mixin Ohme, myenergi and GivEnergy EVC each carry their own copy of the same control loop. CarChargerControl owns it once: read the per-car plan, charge inside a window and stop outside one, re-apply on drift, and release on read only or when the control switch is turned off, with the switch persisted across restarts. Components supply only the charger-specific send, release, connected and drift checks. Nothing uses it yet; the components move onto it in following commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(gecloud): drive EV chargers through the shared charger control GivEnergy EVC control moves onto CarChargerControl. The component keeps what is GivEnergy's own: which chargers exist and in what order, whether a car is connected, and the start-charge / stop-charge commands. Behaviour is unchanged: the switch keeps its storage key, chargers without a car are still left alone, and release still only restarts a charger Predbat had stopped. The EVC read only check now falls back to the set_read_only arg, like the other chargers, rather than the switch entity; the fallback only applies before Predbat sets the attribute at startup. The test double's get_arg answers set_read_only from the same flag as its switch, as Predbat does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(myenergi): drive Zappis through the shared charger control Zappi control moves onto CarChargerControl. The component keeps what is myenergi's own: Fast inside a window and Stopped outside one, drift detected from the polled mode, and release back to the saved mode or Eco+. One deliberate change: a Zappi with no car to follow (more Zappis than num_cars, briefly true while auto-config's num_cars reaches the base) is now left alone rather than Stopped, matching what GivEnergy EVC control already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(ohme): drive the charger through the shared charger control Ohme control moves onto CarChargerControl. The component keeps what is Ohme's own: max charge inside a window and pause outside one, drift detected from the session mode, and release back to Ohme's smart schedule with the user's target percent restored. Ohme has no control switch, so read only stays the only release. Its inline plan parser goes too, in favour of the shared one. That fixes a New Year case the copy missed: read just after midnight on 1 January, a window that started on 31 December parsed as the following December, so a charge running across midnight was paused. A release Ohme refuses is now retried on the next control cycle rather than attempted once, so a paused car is not stranded by one failed call. The control tick is wrapped the way myenergi's already is: a refused command logs a warning instead of failing the whole run, which would otherwise repeat every minute while read only stays on. The mixin now checks for a switch before touching storage, so a component with no switch never needs a storage attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): address #5284 review - release chargers whose car has gone, retry refused GE commands - A charger Predbat holds that falls beyond the cars it follows (num_cars dropped) was never commanded again, left charging or stopped with nobody in control. apply() now releases it. - GivEnergy's async_send_evc_command() reports a refusal by returning None, so a refused release was taken as done and a stopped charger stranded - the failure the Ohme and myenergi retries already close. GE charger control now raises EVCCommandFailed on a refusal, so the command is not recorded and is tried again next cycle; GE's run loop catches it as a warning, as Ohme's and myenergi's already do. A refused start or stop is retried the same way rather than recorded as sent. - Docstring for a nested test helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): one charger refusing no longer blocks the others, and a released Zappi forgets its mode Found by a later review, in code from this PR: - Releasing, releasing a charger whose car has gone, and driving from the plan ran as one loop, so the first charger to refuse a command stopped every charger after it - and each cycle retried in the same order, so they stayed blocked. With two Zappis held Stopped and read only on, a refusing Zappi 1 left Zappi 2 Stopped for good. Each charger is now handled on its own; the first failure is still raised after the rest have had their turn, for the run loop to log and retry. A charger is forgotten as soon as its own release goes through, so a retry re-sends only to the one that refused. - A Zappi released on its own (its car gone when num_cars dropped) kept its saved mode, so taking it back later saved nothing new and the next release restored the stale mode. The saved mode is now dropped once that Zappi's release goes through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(car): leave a charger to Octopus only when Octopus drives the charger Charger control now decides per car whether Octopus Intelligent is in charge of the charger. Where Octopus delivers its dispatches by driving the charger itself, Predbat leaves it alone - releasing it if it was holding it - so the two never fight. Where Octopus drives the car, or there is no Intelligent car, Predbat drives the charger from the car slot sensor, which then carries the Octopus dispatches, so the car cannot charge outside them on its own timers. Octopus car N is the Nth wired Intelligent device, and the Octopus component already records whether each is a charge point. When that cannot be told - slots from outside the Octopus component, or before it has discovered its devices - the charger is left alone, unless the component's control setting is explicitly true. Nothing turns control on by default yet; that follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(gecloud): turn EV charger control on with ge_cloud_automatic_evc ge_cloud_evc_control no longer defaults to false. Left unset, control follows ge_cloud_automatic_evc: setting that is the user asking Predbat to plan for the car, and it is what maps each charger to its car. An explicit false keeps control off, and only an explicit true warns when a prerequisite is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ohme): turn charger control on with ohme_automatic ohme_control no longer defaults to false. Left unset, control follows ohme_automatic, which registers the car. Where the Intelligent slots come from the Ohme, Octopus drives the charger and Predbat still leaves it alone - logged as Info for an unset control, and as a warning only when ohme_control was explicitly set true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(myenergi): turn Zappi control on when the user wrote myenergi_automatic myenergi_zappi_control no longer defaults to false. Left unset, control turns on only when the user wrote myenergi_automatic or myenergi_automatic_zappi into apps.yaml themselves. Both default on, so following their values alone - the rule GE and Ohme use - would switch every Zappi user to Predbat control on upgrade, and a Zappi held off between windows can no longer divert solar in Eco/Eco+. The options considered are recorded in enable_control()'s docstring for review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(car): charger control turns on with automatic setup, and Octopus Intelligent A new "Predbat starting and stopping your charger" section in car-charging.md sets out when each charger's control turns on while its setting is unset, the upgrade change for existing automatic users, and what happens on Octopus Intelligent depending on whether Octopus drives the charger or the car. The per-setting entries in apps-yaml.md and components.md now describe the unset default instead of false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): judge Octopus per car from its own dispatch sensor, and never start a charge handing over Found by review before opening the stage 2 PR: - Read is_charger from the car's own wired octopus_intelligent_slot sensor instead of pairing car N with the Nth sorted Octopus device. Both the Octopus and Kraken components publish it there, so E.ON/EDF SmartFlex users are covered and a slot list in a different order can no longer pair a car with another car's device. - Only wait for Octopus discovery when the Octopus component runs its automatic setup. With octopus_automatic off it never wires the slots, so control never turned on. "Cannot tell yet" also gets its own log line rather than claiming the car is on IOG. - Handing a charger to Octopus no longer starts it. A normal release undoes a stop so a car is never stranded, but here Octopus decides when to charge: a GE charger Predbat stopped is left for Octopus, an Ohme has max charge turned off before it is resumed into its smart schedule, and a Zappi returns to its own mode, Eco+ in place of Fast. - The control settings have no default, which also skipped get_arg()'s boolean conversion, so a quoted "false" or a 0 read as set. parse_control_setting() normalises them to None, True or False in each component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ohme): only turn control on by default once the car's size and limit are set Predbat drives an Ohme with max charge, which overrides the target set in the Ohme app, so car_charging_battery_size and car_charging_limit are all that stop a charge. Left at their defaults (100 kWh, 100%) the car would be charged to full. Unset ohme_control now waits for both to be in apps.yaml and logs why; an explicit ohme_control: true is still the user's call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(car): Ohme size and limit guard, Kraken, and handing over to Octopus Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): a charger Octopus drives stays with Octopus, and a car Octopus drives follows its dispatch sensor - switch.predbat_octopus_intelligent_charging only changes Predbat's own planning: Octopus goes on switching a charger that is its Intelligent device whatever the switch says. The device is now judged first, so turning the switch off no longer has Predbat fight Octopus for that charger. For a car Octopus drives, the switch still decides whether the charger follows the dispatches or Predbat's own plan. - Where Octopus drives the car, the charger also runs while the car's dispatch sensor is on. The plan carries the dispatches but is only republished every 5 minutes and leaves out a dispatch Octopus has not given any energy yet, so the charger held the car off for the first minutes of a new dispatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(car): guest charging switch for chargers Predbat drives While Predbat drives a charger it holds it off outside the planned windows, so a visitor's car could not charge - and stage 2 turns control on by default. Each component (GE, myenergi, Ohme) now publishes switch.predbat_<component>_guest_charging beside its control. On, Predbat releases the charger as it does for read only mode, so it runs on its own settings; how the guest charges is chosen on the charger itself. It turns itself off when a connected car is unplugged, on chargers that can tell (GE), and otherwise after GUEST_CHARGING_MAX_HOURS (12). It is deliberately not persisted, so a restart puts Predbat back in charge. The guest's charging is still counted as the car's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(car): end guest charging when the car is unplugged from a Zappi or Ohme Both report whether a car is plugged in - the Zappi's plug status, the Ohme's UNPLUGGED status - so guest charging now ends on unplug for every charger, not just GE. A new charger_control_car_plugged() hook carries it, separate from charger_control_connected(), so how Predbat drives an empty Zappi or Ohme is unchanged. An unknown plug state reads as plugged in, leaving the 12 hour limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): guest charging survives the owner unplugging, and dispatch ends are judged by the clock Found by review before opening the stage 2 PR: - Guest charging ended on any unplug, including the owner's car being unplugged to make way for the guest. Only a car plugged in after the switch went on now ends it (empty, plugged in, empty again); a guest who plugged in first is left to the 12 hour limit or a manual off. - A GE status blip (offline, unavailable, unknown) read as an unplug and ended guest charging. Only "available" and "idle" count as empty now, as the Zappi and Ohme already treat unknown states as plugged in. - The charger followed the dispatch sensor's on/off state, which only refreshes every couple of minutes and so ran the charger past the end of a dispatch. The dispatch times on the sensor are now judged against the clock, in both the Octopus and the Kraken formats, falling back to the state for a sensor with no times. - A toggle on the guest switch now flips it rather than turning it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(car): warn when Predbat cannot tell whether Octopus drives a charger A dispatch sensor that does not say which device Octopus drives (the Octopus Energy integration, for one) leaves the charger uncontrolled for good, and was only an Info line. It is now a warning naming the component's own setting to set true. Waiting for the Octopus component to discover its devices, which normally clears within a cycle or two, stays Info. Each reason is logged once, and again only when it changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): wait for Kraken to wire its SmartFlex devices before driving a charger (#5311 review) Only the Octopus component was waited for. After a restart a Kraken (E.ON/EDF SmartFlex) user's charger could be driven before Kraken had wired octopus_intelligent_slot - stopping a GE charger mid-dispatch, which the hand-over on the next cycle then left stopped. Kraken wires its devices in its first successful run, from cache or fresh discovery, so until it has started the charger is left alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): release a held charger when Predbat cannot tell who drives it, and stop waiting on discovery after 15 minutes (#5311 review) - Cannot tell whether Octopus drives a charger: a charger Predbat had stopped was let go without being started, as if Octopus were taking it over. With nobody known to take it over, a GE charger was left stopped indefinitely. It is now released as usual; the non-starting hand-over is kept for a charger Octopus is known to drive. - Waiting for Octopus or Kraken discovery is bounded at 15 minutes. A Kraken component whose first run never succeeds (a failed login) otherwise left every unwired car's charger uncontrolled for good. The log line now names both components. - The apps.yaml default's literal "re:" string, before its regex matches, is no longer read as a dispatch sensor - which on every restart warned users with no Intelligent sensor to set a control override they do not need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): leave a held charger as it is while discovery runs, and treat a discovery that never finishes as cannot tell (#5311 review) Releasing during discovery started a charge Predbat had stopped whenever the Octopus or Kraken component restarted, and driving the charger once the wait ran out could fight Kraken if it was the one driving it. While discovery is under way the charger is now left as it is and still held, so it is settled either way once discovery finishes. After 15 minutes Predbat still cannot tell, so it warns once and hands back a charger it holds, as for a dispatch sensor that does not say. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): start each wait for discovery afresh once a charger's driver is known (#5311 review) The wait's start was only cleared when discovery was asked about with nothing discovering, which stops happening once every car's driver is known. A later component restart could then find the old start and count the wait as already over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ohme): Predbat drives the Ohme only with ohme_control: true, so its own schedule stays the car plan otherwise Main's #5401 now takes the car plan from Ohme's own schedule when Predbat is not controlling the charger, so a default user already gets a plan that matches what the charger will do. Turning control on by default would override that schedule - one the user may have set up deliberately - with max charge, which also overrides the Ohme app's target. ohme_control goes back to a false default; GE and Zappi keep their automatic defaults. The size and limit guard for an unset ohme_control goes, as there is no unset default any more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(car): octopus_intelligent_charger_follows_car - leave a charger alone when Octopus drives the car, unless asked to follow it When the car itself is the Octopus Intelligent device, the car starts and stops its own charge. Predbat now leaves the charger alone by default, handing over a charger it held without starting a charge, as it does when Octopus drives the charger. The new expert-mode switch, default off, has Predbat drive the charger to the car's dispatches instead, so the car cannot also charge on its own timers. The Ohme follows the same switch: with it on, ohme_control can run while Octopus drives the car, where #5405 otherwise stands it down. A change of the switch is picked up on the next Ohme poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): release a held charger when Octopus drives the car, keep following Predbat's own plan with Intelligent charging off, and log only real Ohme changes (#5311 review) - Octopus driving the car means nobody drives the charger, so a charger Predbat had stopped is released as usual (GE started, Zappi back to its own mode including Fast) rather than handed over without starting, which left it stopped through every dispatch. - With switch.predbat_octopus_intelligent_charging off Predbat plans the car itself, so it still drives the charger to that plan whatever the new switch says. - An Ohme poll that sees only the new switch change logs that change, and only re-decides control while Octopus drives another device - not the Octopus tariff change it used to report. - The log points at expert mode for the switch, and the class docstring says the charger is left alone by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ohme): drive the Ohme to Predbat's own plan with Intelligent charging off while Octopus drives the car (#5311 review) The shared charger rule drives a charger for a car Octopus drives when switch.predbat_octopus_intelligent_charging is off, as Predbat then plans the car itself; the Ohme stood control down there regardless. It now follows the same rule, and re-decides when either switch changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): read a dispatch time with no offset as UTC, and a lost is_charger value as cannot tell (local review) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(car): hand an Ohme to Octopus without starting a charge, and warn when Storage refuses to save the control switch (#5448 review) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * refactor(car): share the hand-over to Octopus, and only cancel a max charge Predbat started (#5448 review) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(car): time each car's wait for Octopus discovery on its own (local review) The discovery wait was one clock shared by every car, and drive_one reset it whenever any car's driver was known. With two cars - one wired, one still waiting on a Kraken login that keeps failing - the wired car reset the clock every cycle, so the waiting car never reached "cannot tell" and its charger was held as it was indefinitely, with no warning. The clock is now kept per car, and still cleared for every car once discovery has finished. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(debug-journal): restore control_active for the Sunsynk/Deye persistence clause (#5448 review) The myenergi rename to charger_control_active caught the Sunsynk/Deye sentence too; those components still persist control_active. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): leave a charger alone while its own car's plan is unpublished (#5448 review) charger_control_refresh_windows() only waited for any car's plan, so with car 0's slot sensor published and car 1's not, car 1 read as an empty plan and its charger was stopped. A car missing from charger_control_windows is now unknown: its charger is left as it is until its plan appears or an Octopus dispatch says to charge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): keep driving a held charger whose car's plan is unpublished (local review) The unknown-plan skip came before the drift and last-state checks, so a charger a dispatch had started for a car with no published plan was frozen on once the dispatch ended. The skip now applies only to a charger Predbat does not hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): keep a car's last plan when its slot sensor briefly loses it (local review) A held charger whose car's planned attribute went missing for a cycle was driven from an empty window list and stopped mid-charge. The windows are absolute times, so the last ones read are carried forward instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(ohme): a refused hand-back no longer fails the whole poll (local review) update_slot_mode() handed the charger back outside any handler, so an ApiException from Ohme skipped the session fetch, publish and slot wiring for every poll until Ohme accepted the command. It is now a warning, as in the control tick; the charger stays held and the next poll retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(myenergi): save a Zappi's mode only once Predbat's command has gone through (local review) The mode was snapshotted ahead of set_mode, so a refused command left a snapshot for a Zappi Predbat never held. Release paths only run for held Zappis, so it could outlive the moment and be restored much later in place of what the user had set by then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(output): publish each car's slot sensor with only its own windows publish_car_plan() built one plan list for every car, and handed the same list object to each slot sensor, so with two or more cars every sensor's 'planned' attribute held the windows of all of them. Pre-existing on main, but the shared charger control drives charger N from car N's 'planned', so a charger would start in another car's window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revert "fix(myenergi): save a Zappi's mode only once Predbat's command has gone through (local review)" This reverts commit 3ff61b7. Snapshotting after set_mode traded one hazard for a worse one: a command that timed out but was applied is retried with the Zappi already Stopped, and Stopped was then saved as the user's mode, so a later release left the car unable to charge. Saving ahead of the command gets the mode the user really had; a snapshot left by a refused command is the lesser risk, and a full release still clears it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(myenergi): never release or hand over a Zappi back to Stopped (local review) A saved Stopped is most likely Predbat's own hold: after a restart, or a Stop that timed out but was applied, the first snapshot is taken with the Zappi already Stopped. Restoring it left the car unable to charge, the very outcome a release exists to prevent. It now falls back to Eco+, as for no saved mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(ohme): use charger_control_active in the #5443 dispatch-sensor tests The tests main added in #5443 read OhmeAPI.control_active, which this branch renamed to charger_control_active when control moved into the shared CarChargerControl mixin. Only the attribute name changes; the assertions are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(charger-control): judge each car on its own dispatch sensor under an Ohme claim Since #5443 the Octopus component keeps wiring its whole dispatch-sensor list into octopus_intelligent_slot while Ohme holds car_slot_owner (the claim now covers only the ready time and charge limit). The car_slot_owner shortcut in charger_control_octopus_drives_charger()/_drives_car() predates that and applied to every car, so with an Ohme on car 0 and a second car whose Intelligent device is the car itself, a Zappi or GE EVC on car 1 was left to Octopus: follows_car ignored, and a held charger handed over stopped. The sensor's own is_charger now decides; the claim only stands in when the sensor does not say (the Ohme's own slot sensor). drives_car no longer reads an "unknown" is_charger as "the car". Regression test fails on the merge without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(charger-control): keep the Ohme claim deciding car 0, as before the merge The previous commit let car 0's own dispatch sensor override an Ohme car_slot_owner claim. That changed behaviour the merge never touched: with ohme_automatic_octopus_intelligent forced on, or Octopus's device ids sorting the car's sensor first, a charger on car 0 that used to be left to Octopus would be released or driven. Car 0 goes back to the claim alone; only cars after it, which had no slot at all under the claim before #5443, are judged on their own sensor - and one whose sensor has lost its value is "cannot tell" rather than handed to Octopus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(charger-control): say what an Ohme claim covers since #5443 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(car): charger control stays off unless set, and a Zappi's own mode survives a restart Review changes from Trefor on #5448. ge_cloud_evc_control and myenergi_zappi_control default to false again, so an upgrade never starts Predbat driving a charger nobody asked it to drive. Control runs only with an explicit true, and still stands down where Octopus Intelligent drives the charger or the car. MyEnergiAPI.automatic_set_by_user() is removed. The mode a Zappi had before Predbat took over is now saved to storage and restored on startup, so a restart no longer snapshots Predbat's own Stopped as the mode to release to. With that fixed, a saved Stopped is restored like any other mode, on release and on the hand-over to Octopus (which still never uses Fast). Modes left in storage are dropped when control is off at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: CI <ci@example.com>
1 parent 9247831 commit c000b53

16 files changed

Lines changed: 2689 additions & 565 deletions

‎apps/predbat/car_charger_control.py‎

Lines changed: 656 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/predbat/config.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,13 @@
883883
"type": "switch",
884884
"default": True,
885885
},
886+
{
887+
"name": "octopus_intelligent_charger_follows_car",
888+
"friendly_name": "Drive the charger to the Intelligent dispatches when Octopus drives the car",
889+
"type": "switch",
890+
"default": False,
891+
"enable": "expert_mode",
892+
},
886893
{
887894
"name": "octopus_saving_auto_join",
888895
"friendly_name": "Octopus Saving Session Auto Join",

‎apps/predbat/gecloud.py‎

Lines changed: 64 additions & 126 deletions
Large diffs are not rendered by default.

‎apps/predbat/myenergi.py‎

Lines changed: 119 additions & 143 deletions
Large diffs are not rendered by default.

‎apps/predbat/ohme.py‎

Lines changed: 108 additions & 114 deletions
Large diffs are not rendered by default.

‎apps/predbat/output.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,11 @@ def publish_car_plan(self):
132132
"""
133133
Publish the car charging plan
134134
"""
135-
plan = []
136135
postfix = ""
137136
for car_n in range(self.num_cars):
137+
# A list of its own per car - shared, every car after the first also carried the windows of
138+
# the cars before it, and a charger driven from its car's plan would charge in them
139+
plan = []
138140
if car_n > 0:
139141
postfix = "_" + str(car_n)
140142
if not self.car_charging_slots[car_n]:

‎apps/predbat/tests/test_car_charger_control.py‎

Lines changed: 975 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/predbat/tests/test_car_charging_smart.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,39 @@ def run_car_charging_smart_tests(my_predbat):
159159
failed |= run_car_charging_slot_integer_test("smart_int3_issue3911", my_predbat, battery_size=77.0, soc=74.3, limit=77.0, rate=11.0, loss=0.9, plan_time="07:00:00")
160160

161161
failed |= run_car_charging_start_stable_test(my_predbat)
162+
failed |= run_car_plan_published_per_car_test(my_predbat)
162163

163164
return failed
164165

165166

167+
def run_car_plan_published_per_car_test(my_predbat):
168+
"""
169+
Each car's slot sensor carries only that car's windows - a charger is driven from it, so a
170+
window of another car's would start it charging off-plan.
171+
"""
172+
failed = False
173+
saved_num_cars = my_predbat.num_cars
174+
saved_slots = my_predbat.car_charging_slots
175+
saved_dashboard = copy.deepcopy(my_predbat.dashboard_values)
176+
try:
177+
my_predbat.num_cars = 3
178+
window = {"start": my_predbat.minutes_now + 60, "end": my_predbat.minutes_now + 120, "kwh": 5.0, "average": 10.0, "cost": 50.0}
179+
my_predbat.car_charging_slots = [[window], [], [dict(window, start=window["start"] + 120, end=window["end"] + 120)]]
180+
my_predbat.publish_car_plan()
181+
planned = [my_predbat.dashboard_values["binary_sensor.predbat_car_charging_slot" + postfix]["attributes"]["planned"] for postfix in ("", "_1", "_2")]
182+
if [len(plan) for plan in planned] != [1, 0, 1] or planned[0][0]["start"] == planned[2][0]["start"]:
183+
print("ERROR: each car's slot sensor should carry only its own windows, got {}".format(planned))
184+
failed = True
185+
else:
186+
print("OK: each car's slot sensor carries only its own windows")
187+
finally:
188+
my_predbat.num_cars = saved_num_cars
189+
my_predbat.car_charging_slots = saved_slots
190+
my_predbat.dashboard_values.clear()
191+
my_predbat.dashboard_values.update(saved_dashboard)
192+
return failed
193+
194+
166195
def run_car_charging_start_stable_test(my_predbat):
167196
"""
168197
The published car charge start time must not walk forward once a slot is underway.

‎apps/predbat/tests/test_ge_cloud.py‎

Lines changed: 102 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from gecloud import GECloudDirect, GECloudData, regname_to_ha
1313
from gecloud import GE_API_ACCOUNT, GE_API_DEVICES, GE_API_EVC_SEND_COMMAND, GE_API_INVERTER_WRITE_SETTING, GE_API_SITE
14-
from gecloud import GECloudTerminalError, SITE_MAX_AGE_MINUTES, parse_site_export_limit
14+
from gecloud import EVCCommandFailed, GECloudTerminalError, SITE_MAX_AGE_MINUTES, parse_site_export_limit
1515
from gecloud import DEVICE_REFRESH_SECONDS, SETTINGS_SLOW_REFRESH_SECONDS, find_ems_slot_overrides, normalise_register_time
1616
from utils import dp4
1717
import asyncio
@@ -57,12 +57,9 @@ def __init__(self):
5757
self.evc_sessions = {}
5858
self.evc_status_unknown = set()
5959
self.automatic_evc = False
60+
# Off, the components.py default when apps.yaml does not mention it
6061
self.evc_control = False
61-
self.evc_control_active = False
62-
self.evc_control_enabled = True
63-
self.evc_control_released = False
64-
self.evc_control_state = {}
65-
self.evc_control_windows = {}
62+
self.charger_control_setup("GECloud", "EV charger", "gecloud", "evc_control_state", "evc_control_enabled", switch_prefix="gecloud")
6663
self.entity_states = {}
6764
self.entity_attributes = {}
6865
self.pending_writes = {}
@@ -131,7 +128,9 @@ def dashboard_item(self, entity_id, state, attributes, app=None):
131128
self.dashboard_items[entity_id] = {"state": state, "attributes": attributes}
132129

133130
def get_arg(self, name, default=None, **kwargs):
134-
"""Mock get_arg"""
131+
"""Mock get_arg - set_read_only answers from the same flag as the read only switch, as it does in Predbat"""
132+
if name == "set_read_only":
133+
return self._read_only
135134
return self.config_args.get(name, default)
136135

137136
def set_arg(self, name, value):
@@ -3456,7 +3455,7 @@ async def test():
34563455
print("ERROR: EVC automatic config should re-run once, got {}".format(calls["automatic_evc"]))
34573456
return 1
34583457

3459-
ge_cloud.evc_control_state["evc-1"] = "start"
3458+
ge_cloud.charger_control_state["evc-1"] = True
34603459
api["evc"] = [{"uuid": "evc-2", "alias": "Second"}]
34613460
_reset_rediscovery_calls(calls)
34623461
await ge_cloud.run(seconds=DEVICE_REFRESH_SECONDS * 2, first=False)
@@ -3466,7 +3465,7 @@ async def test():
34663465
if "evc-1" in calls["evc_polled"]:
34673466
print("ERROR: the removed charger should not be polled, got {}".format(calls["evc_polled"]))
34683467
return 1
3469-
for name in ("evc_device", "evc_data", "evc_sessions", "evc_control_state"):
3468+
for name in ("evc_device", "evc_data", "evc_sessions", "charger_control_state"):
34703469
if "evc-1" in getattr(ge_cloud, name):
34713470
print("ERROR: {} should no longer hold the removed charger".format(name))
34723471
return 1
@@ -6324,17 +6323,23 @@ async def test():
63246323
outside = tz.localize(datetime(2026, 8, 23, 6, 0))
63256324
plan = {EVC_PLAN_SENSOR: {"planned": [{"start": "08-22 23:00:00", "end": "08-23 05:00:00"}]}}
63266325

6327-
# Test 1: control stays off unless it is asked for
6326+
# Test 1: control is off by default, and ge_cloud_automatic_evc alone does not turn it on
6327+
ge = MockGECloudDirect()
6328+
ge.automatic_evc = True
6329+
ge.evc_control_enable()
6330+
assert ge.charger_control_active is False, "ge_cloud_automatic_evc alone must not turn charger control on"
6331+
assert not any("Warn" in message for message in ge.log_messages), "Control that was never asked for is not warned about"
63286332
ge = MockGECloudDirect()
63296333
ge.automatic_evc = True
6334+
ge.evc_control = True
63306335
ge.evc_control_enable()
6331-
assert ge.evc_control_active is False, "Control should be off without ge_cloud_evc_control"
6336+
assert ge.charger_control_active is True, "ge_cloud_evc_control: true with ge_cloud_automatic_evc turns control on"
63326337

6333-
# Test 2: and refuses to run without the auto-config that maps chargers to cars
6338+
# Test 2: an explicit request refuses to run without the auto-config that maps chargers to cars
63346339
ge = MockGECloudDirect()
63356340
ge.evc_control = True
63366341
ge.evc_control_enable()
6337-
assert ge.evc_control_active is False, "Control needs ge_cloud_automatic_evc to know which charger is which car"
6342+
assert ge.charger_control_active is False, "Control needs ge_cloud_automatic_evc to know which charger is which car"
63386343
assert any("ge_cloud_automatic_evc" in message for message in ge.log_messages), "The reason control is off should be logged"
63396344

63406345
# Test 3: a planned window starts the charger, and is not re-sent every poll
@@ -6344,27 +6349,27 @@ async def test():
63446349
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}}
63456350
ge.entity_attributes = plan
63466351

6347-
await ge.evc_control_charge(inside)
6352+
await ge.charger_control_apply(inside)
63486353
assert commands == [("evc-001", "start-charge")], "A planned window should start the charge, got {}".format(commands)
63496354

63506355
commands.clear()
6351-
await ge.evc_control_charge(inside)
6356+
await ge.charger_control_apply(inside)
63526357
assert commands == [], "The same state should not be re-sent, got {}".format(commands)
63536358

63546359
# Test 4: outside the window the charger is stopped
63556360
commands.clear()
6356-
await ge.evc_control_charge(outside)
6361+
await ge.charger_control_apply(outside)
63576362
assert commands == [("evc-001", "stop-charge")], "Outside a window the charge should stop, got {}".format(commands)
63586363

63596364
# Test 5: read only mode hands the charger back, once
63606365
commands.clear()
63616366
ge._read_only = True
6362-
await ge.evc_control_tick(outside)
6367+
await ge.charger_control_tick(outside)
63636368
assert commands == [("evc-001", "start-charge")], "Releasing should hand a stopped charger back, got {}".format(commands)
6364-
assert ge.evc_control_released is True, "The release should be remembered"
6369+
assert ge.charger_control_released is not None, "The release should be remembered"
63656370

63666371
commands.clear()
6367-
await ge.evc_control_tick(outside)
6372+
await ge.charger_control_tick(outside)
63686373
assert commands == [], "A release should happen once, not every cycle"
63696374

63706375
# Test 6: turning the control switch off releases in the same way
@@ -6373,20 +6378,48 @@ async def test():
63736378
ge.evc_device_list = ["evc-001"]
63746379
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}}
63756380
ge.entity_attributes = plan
6376-
await ge.evc_control_charge(outside)
6381+
await ge.charger_control_apply(outside)
63776382
commands.clear()
63786383
await ge.switch_event("switch.predbat_gecloud_evc_control", "turn_off")
6379-
assert ge.evc_control_enabled is False, "The switch should turn control off"
6380-
await ge.evc_control_tick(outside)
6384+
assert ge.charger_control_enabled is False, "The switch should turn control off"
6385+
await ge.charger_control_tick(outside)
63816386
assert commands == [("evc-001", "start-charge")], "Switching control off should release the charger, got {}".format(commands)
63826387

6388+
# Test 6b: the guest charging switch releases in the same way, and ends when the guest unplugs -
6389+
# but not when the owner's car, on the charger when it went on, is unplugged to make way
6390+
commands = []
6391+
ge = _evc_control_component(commands)
6392+
ge.evc_device_list = ["evc-001"]
6393+
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}}
6394+
ge.entity_attributes = plan
6395+
await ge.charger_control_apply(outside)
6396+
commands.clear()
6397+
await ge.switch_event("switch.predbat_gecloud_guest_charging", "turn_on")
6398+
assert ge.charger_control_guest is True, "The guest switch should turn guest charging on"
6399+
await ge.charger_control_tick(outside)
6400+
assert commands == [("evc-001", "start-charge")], "Guest charging should release the charger, got {}".format(commands)
6401+
ge.evc_device["evc-001"]["status"] = "idle"
6402+
await ge.charger_control_tick(outside)
6403+
assert ge.charger_control_guest is True, "The owner's car being unplugged should not end guest charging"
6404+
ge.evc_device["evc-001"]["status"] = "charging"
6405+
await ge.charger_control_tick(outside)
6406+
# A comms blip is not an unplug
6407+
ge.evc_device["evc-001"]["status"] = "Unavailable"
6408+
await ge.charger_control_tick(outside)
6409+
assert ge.charger_control_guest is True, "An unavailable status should not end guest charging"
6410+
ge.evc_device["evc-001"]["status"] = "charging"
6411+
await ge.charger_control_tick(outside)
6412+
ge.evc_device["evc-001"]["status"] = "idle"
6413+
await ge.charger_control_tick(outside)
6414+
assert ge.charger_control_guest is False, "Unplugging the guest's car should end guest charging"
6415+
63836416
# Test 7: nothing is commanded while no car is plugged in
63846417
commands = []
63856418
ge = _evc_control_component(commands)
63866419
ge.evc_device_list = ["evc-001"]
63876420
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "idle"}}
63886421
ge.entity_attributes = plan
6389-
await ge.evc_control_charge(inside)
6422+
await ge.charger_control_apply(inside)
63906423
assert commands == [], "An empty charger should not be commanded, got {}".format(commands)
63916424

63926425
# Test 8: nothing is commanded before Predbat has published a plan, so a restart
@@ -6395,7 +6428,7 @@ async def test():
63956428
ge = _evc_control_component(commands)
63966429
ge.evc_device_list = ["evc-001"]
63976430
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}}
6398-
await ge.evc_control_charge(inside)
6431+
await ge.charger_control_apply(inside)
63996432
assert commands == [], "With no plan published nothing should be commanded, got {}".format(commands)
64006433

64016434
# Test 9: charger N is car N by serial order, matching the automatic configuration
@@ -6408,7 +6441,7 @@ async def test():
64086441
}
64096442
ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR], EVC_PLAN_SENSOR_CAR_1: {"planned": []}}
64106443

6411-
await ge.evc_control_charge(inside)
6444+
await ge.charger_control_apply(inside)
64126445
assert sorted(commands) == sorted([("evc-second", "start-charge"), ("evc-first", "stop-charge")]), "The lower serial should be car 0, got {}".format(commands)
64136446

64146447
# Test 10: a charger with no car index yet is left alone rather than stopped.
@@ -6423,9 +6456,52 @@ async def test():
64236456
}
64246457
ge.entity_attributes = {EVC_PLAN_SENSOR: plan[EVC_PLAN_SENSOR]}
64256458

6426-
await ge.evc_control_charge(inside)
6459+
await ge.charger_control_apply(inside)
64276460
assert commands == [("evc-first", "start-charge")], "Only the charger with a car should be commanded, got {}".format(commands)
64286461

6462+
# Test 11: a command GE Cloud refuses is not recorded as sent, so it is tried again
6463+
commands = []
6464+
ge = _evc_control_component(commands)
6465+
ge.evc_device_list = ["evc-001"]
6466+
ge.evc_device = {"evc-001": {"serial_number": "EVC100", "status": "charging"}}
6467+
ge.entity_attributes = plan
6468+
refused = []
6469+
6470+
async def refuse(uuid, command, params):
6471+
"""Refuse the command, as async_send_evc_command reports it: None."""
6472+
refused.append((uuid, command))
6473+
return None
6474+
6475+
real_send = ge.async_send_evc_command
6476+
ge.async_send_evc_command = refuse
6477+
try:
6478+
await ge.charger_control_apply(outside)
6479+
assert False, "A refused command should raise"
6480+
except EVCCommandFailed:
6481+
pass
6482+
assert ge.charger_control_state == {}, "A refused stop must not be recorded, got {}".format(ge.charger_control_state)
6483+
6484+
ge.async_send_evc_command = real_send
6485+
await ge.charger_control_apply(outside)
6486+
assert commands == [("evc-001", "stop-charge")], "The stop should be retried, got {}".format(commands)
6487+
6488+
# Test 12: a refused release is not latched as done, so the stopped charger is not stranded
6489+
commands.clear()
6490+
ge._read_only = True
6491+
ge.async_send_evc_command = refuse
6492+
try:
6493+
await ge.charger_control_tick(outside)
6494+
assert False, "A refused release should raise"
6495+
except EVCCommandFailed:
6496+
pass
6497+
assert ge.charger_control_released is None, "A refused release must not be recorded as done"
6498+
assert ge.charger_control_state == {"evc-001": False}, "Predbat still holds the stopped charger"
6499+
6500+
ge.async_send_evc_command = real_send
6501+
await ge.charger_control_tick(outside)
6502+
assert commands == [("evc-001", "start-charge")], "The release should be retried, got {}".format(commands)
6503+
assert ge.charger_control_released is not None
6504+
64296505
return 0
64306506

64316507
return run_async(test())

0 commit comments

Comments
 (0)