OPT-11 P3: price runs per battery
Cheap and expensive hours as runs of consecutive firm price slots
(priceruns.py); each battery's role per tick from its mode (modetrade.py):
grid charging for Leftover solar / Cheap hours only, never Solar first;
holding for the expensive runs; selling per battery (pack_sell) above the
spread and the floor. The site trade is sized inside the modes (loop bound)
and the peak guard's headroom; a run edge reseeds the loop. Cheap hours only
and selling are enabled on the Batteries screen and in the HA select
(appended). The site Why row no longer says "held back" when the peak limit
pushes power up (F2). Modes off: byte-identical to 7a00f12 on the trade path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+292
-12
@@ -37,11 +37,13 @@ import aiohttp
|
||||
|
||||
from . import arbitrage
|
||||
from .arbiter import (
|
||||
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY,
|
||||
Claim, Resolution, actuator_for, pre_resolve, resolve,
|
||||
ACTUATOR_SITE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK,
|
||||
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
|
||||
)
|
||||
from . import allocator
|
||||
from . import modetrade
|
||||
from . import packmode
|
||||
from . import priceruns
|
||||
from . import control as _ctl
|
||||
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
|
||||
from .delivery import (
|
||||
@@ -80,7 +82,9 @@ from . import packshare
|
||||
from . import pv as pv_read
|
||||
from . import shadow
|
||||
from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Uncertainty, brussels, price_option, provider_configured
|
||||
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
||||
from .price import required_spread as price_required_spread
|
||||
from .price import uncertainty_of as price_uncertainty_of
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .tariff import PeakStore, PeakTracker
|
||||
@@ -226,6 +230,24 @@ def load_options() -> dict:
|
||||
|
||||
|
||||
|
||||
# OPT-11 P3: how long the last cheap run stays the price stored energy is
|
||||
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
|
||||
REF_CHEAP_KEEP = timedelta(hours=12)
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
ABOVE what the winner asked (the peak floor asking for more power)? Then
|
||||
the Why line must not say "held back". False when nothing bound it."""
|
||||
if not resolution.bound_by or resolution.contradiction:
|
||||
return False
|
||||
asked = next((c.value for c in sorted(
|
||||
(c for c in claims if c.kind == SET
|
||||
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
|
||||
key=lambda c: c.priority, reverse=True)), None)
|
||||
return asked is not None and resolution.target_w > float(asked)
|
||||
|
||||
|
||||
def pack_energy_of(inv):
|
||||
"""OPT-09. ONE roster entry as the view `packshare` consumes.
|
||||
|
||||
@@ -909,6 +931,17 @@ class Controller:
|
||||
self.modes_on = packmode.enabled(opts)
|
||||
# The units whose mode fault is logged already (edge-logged).
|
||||
self._mode_faults = set()
|
||||
# OPT-11 P3: the price runs (`priceruns.RunPlan`, or None), the key
|
||||
# they were built for, this tick's roles (`modetrade.Tick`), the
|
||||
# batteries that left a run (full, or at the floor) keyed by the run,
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = None
|
||||
self._runs_key = None
|
||||
self.mode_tick = None
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._ref_cheap = None
|
||||
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
|
||||
self._mqtt_refused = set()
|
||||
self._mqtt_refused_more = 0
|
||||
@@ -1982,7 +2015,11 @@ class Controller:
|
||||
# OPT-11: the solar order. While the site charges, Leftover solar
|
||||
# batteries weigh 0, so Solar first batteries fill first. A comfort
|
||||
# choice like the rest of this method: permission is the mode claim.
|
||||
return packmode.charge_weights(base, self.pack_modes(), target_w)
|
||||
w = packmode.charge_weights(base, self.pack_modes(), target_w)
|
||||
# P3: in a price run, the batteries whose run it is carry it first.
|
||||
tick = self.mode_tick
|
||||
return w if tick is None else modetrade.weights(tick.roles, w,
|
||||
target_w)
|
||||
|
||||
def pack_mode(self, inv) -> "packmode.PackMode":
|
||||
"""OPT-11. This battery's on/off switch and mode, read from the store."""
|
||||
@@ -2011,6 +2048,214 @@ class Controller:
|
||||
for u in self.units}
|
||||
return allocator.loop_bound(bounds, live=self.live_units())
|
||||
|
||||
# --- OPT-11 P3: price runs, per battery -------------------------------
|
||||
def _mode_packs(self, maint_results=None) -> list:
|
||||
"""Each battery as `modetrade.Pack`, in roster order."""
|
||||
live = set(self.live_units())
|
||||
out = []
|
||||
for i, inv in enumerate(self.inverters):
|
||||
pm = self.pack_mode(inv)
|
||||
idle = (maint_results is None
|
||||
or maint_results[i].phase == IDLE)
|
||||
refused = (inv.refused if len(self.inverters) > 1
|
||||
else self.batt_refused) or 0
|
||||
out.append(modetrade.Pack(
|
||||
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
|
||||
float(inv.max_w), inv.soc, inv.capacity_kwh,
|
||||
live=inv.name in live, idle=idle, refused=int(refused)))
|
||||
return out
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
"""The kept run plan, rebuilt at most once per price slot (15 min) or
|
||||
when the prices or a battery's choice change; a run under way is
|
||||
kept to its end (`priceruns.keep_started()`). None with no fresh
|
||||
firm prices: then no battery holds or trades (design section 5)."""
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
if plan is None or not plan.has_prices:
|
||||
self.mode_runs, self._runs_key = None, None
|
||||
return None
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
if key != self._runs_key:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
e = modetrade.energy(packs, floor_pct, ceiling_pct, done_c,
|
||||
done_s)
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
price_uncertainty_of(plan.read.product)
|
||||
| {Uncertainty.NO_WEATHER})
|
||||
ref = self._ref_cheap
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
new = priceruns.plan(
|
||||
plan.read.points, now, required_eur_kwh=required,
|
||||
charge_kwh=e[0], charge_kw=e[1], sell_kwh=e[2],
|
||||
sell_kw=e[3],
|
||||
ref_eur_kwh=None if ref is None else ref.mean_eur_kwh)
|
||||
self.mode_runs = priceruns.keep_started(self.mode_runs, new, now)
|
||||
self._runs_key = key
|
||||
runs = self.mode_runs
|
||||
# The cheapest cheap run under way lately is the price the stored
|
||||
# energy was bought at; it stays the reference for REF_CHEAP_KEEP
|
||||
# after it ends, unless a cheaper one comes.
|
||||
ref = self._ref_cheap
|
||||
if runs is not None and runs.cheap is not None \
|
||||
and runs.cheap.holds(now) and (
|
||||
ref is None or now - ref.end > REF_CHEAP_KEEP
|
||||
or runs.cheap.mean_eur_kwh <= ref.mean_eur_kwh):
|
||||
self._ref_cheap = runs.cheap
|
||||
return runs
|
||||
|
||||
def _mode_roles(self, now, maint_results):
|
||||
"""OPT-11 P3: each battery's role and range this tick. Grid charging
|
||||
and selling need a fresh plan that `TariffPlan.arbitrage()` allows
|
||||
with the MEASURED peak guard (None refuses: nothing measures the
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
charge_ok = sell_ok = False
|
||||
trade_why = "not_covered"
|
||||
|
||||
def allowed(active):
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER})).match(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
# A charge asks the guard (section 9.1 interlock); a sell only
|
||||
# needs it to have measured the quarter, since discharge is never
|
||||
# capped by it (a big discharge alone engages it on export).
|
||||
sell_ok = allowed(False)
|
||||
charge_ok = sell_ok and allowed(guard)
|
||||
trade_why = (None if charge_ok else "peak" if sell_ok
|
||||
else "not_worth")
|
||||
price_now = None
|
||||
if plan is not None:
|
||||
try:
|
||||
pt = plan.read.match(series=lambda r: r.at(now),
|
||||
no_data=lambda _r: None)
|
||||
price_now = (None if pt is None or pt.provisional
|
||||
else float(pt.eur_per_kwh))
|
||||
except Absence:
|
||||
price_now = None
|
||||
house = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
done_c = self._run_mark("charge", runs and runs.cheap, packs,
|
||||
lambda p: p.soc is not None
|
||||
and p.soc >= ceiling_pct)
|
||||
done_s = self._run_mark("sell", runs and runs.sell, packs,
|
||||
lambda p: p.soc is not None
|
||||
and p.soc <= floor_pct)
|
||||
quarter = int(now.timestamp() // 900)
|
||||
was = self.mode_tick is not None and \
|
||||
self.mode_tick.action == modetrade.CHARGE
|
||||
self.mode_tick = modetrade.decide(
|
||||
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
||||
trade_why=trade_why, price_now=price_now,
|
||||
headroom_w=self.arb_headroom_w, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
self.mode_tick.why in ("peak", "headroom"):
|
||||
self._charge_blocked_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _run_mark(self, kind, run, packs, out) -> set:
|
||||
"""The batteries that left run `run` (full, or at the floor); they
|
||||
stay out until it ends, so a level edge does not re-arm them."""
|
||||
key = None if not run else (run.start, run.end)
|
||||
was_key, done = self._run_done.get(kind, (None, set()))
|
||||
if key != was_key:
|
||||
done = set()
|
||||
if key is not None:
|
||||
done |= {p.name for p in packs if out(p)}
|
||||
self._run_done[kind] = (key, done)
|
||||
return done
|
||||
|
||||
def _mode_trade(self, claims, now) -> bool:
|
||||
"""OPT-11 P3: file the site trade the roles ask for, sized inside the
|
||||
modes and the hard limits (the loop bound) and, for a charge, the
|
||||
billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79).
|
||||
So no battery that its mode or a refusal holds is asked for watts it
|
||||
may not give (P1's unmet share). Returns True when a trade is filed.
|
||||
"""
|
||||
tick = self.mode_tick
|
||||
if tick is None:
|
||||
return False
|
||||
size = modetrade.size(tick, self.loop_bound(claims)
|
||||
if tick.action else None, self.arb_headroom_w)
|
||||
runs = self.mode_runs
|
||||
if tick.action and (size == 0.0 or runs is None):
|
||||
# Nothing the batteries may place (a refusal, a hard limit): no
|
||||
# trade, and the roles say so rather than "charging".
|
||||
back = {modetrade.CHARGE: modetrade.HOLD,
|
||||
modetrade.REST: modetrade.HOUSE,
|
||||
modetrade.SELL: modetrade.GIVE}
|
||||
tick = self.mode_tick = modetrade.Tick(
|
||||
tick.ranges, {n: back.get(r, r) for n, r in tick.roles.items()},
|
||||
None, 0.0, "size")
|
||||
if size == 0.0 or runs is None:
|
||||
reason = ("battery modes: no price run now" if tick.why is None
|
||||
else f"battery modes: no trade ({tick.why})")
|
||||
self.arb = arbitrage.ArbitrageDecision(
|
||||
action=None, power_w=None, reason=reason,
|
||||
spread_eur_kwh=None, required_eur_kwh=None,
|
||||
price_now_eur_kwh=None)
|
||||
else:
|
||||
charge = tick.action == modetrade.CHARGE
|
||||
run = runs.cheap if charge else runs.sell
|
||||
who = ", ".join(n for n, r in sorted(tick.roles.items())
|
||||
if r == tick.action)
|
||||
act = arbitrage.CHARGE if charge else arbitrage.DISCHARGE
|
||||
self.arb = arbitrage.ArbitrageDecision(
|
||||
action=act, power_w=size,
|
||||
reason=(f"{act}: battery modes, "
|
||||
f"{'cheap' if charge else 'sell'} run "
|
||||
f"{run.start.isoformat()} to {run.end.isoformat()} "
|
||||
f"at {run.mean_eur_kwh:.4f} EUR/kWh; "
|
||||
f"{abs(size):.0f} W on {who}"),
|
||||
spread_eur_kwh=None, required_eur_kwh=runs.level_eur_kwh,
|
||||
price_now_eur_kwh=None)
|
||||
claims.append(arbitrage.claim(self.arb))
|
||||
if self.arb.action != self._arb_action:
|
||||
self._arb_action = self.arb.action
|
||||
self.log_event(self.arb.explain(), hint=messages.hint(
|
||||
"trade_charge" if self.arb.action == arbitrage.CHARGE else
|
||||
"trade_give" if self.arb.action == arbitrage.DISCHARGE
|
||||
else "trade_off"))
|
||||
return self.arb.action is not None
|
||||
|
||||
def _reseed_run_edge(self, claims) -> None:
|
||||
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
|
||||
command no loop chose. Start it at the house's own point E, the
|
||||
command that brings the grid to its target now (`site battery + grid
|
||||
- target`, inside the loop bound), instead of at the trade's watts
|
||||
(the trade began with the same unslewed step). This tick's error still
|
||||
measures the trade's command (it is exactly E - battery), so the
|
||||
integrator is seeded that much short and lands on E after its step.
|
||||
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
|
||||
ending under an 800 W house): no reseed -258 W export then a 2976 W
|
||||
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
|
||||
"""
|
||||
batt = self.site_batt_w
|
||||
if batt is None or self.grid is None:
|
||||
return
|
||||
want = float(batt) + float(self.grid) - self.tuning.target_grid_w
|
||||
bound = self.loop_bound(claims)
|
||||
if bound is not None:
|
||||
want = max(bound[0], min(bound[1], want))
|
||||
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
|
||||
self.target = want
|
||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||
|
||||
def _mode_fault_log(self, resolution) -> None:
|
||||
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
|
||||
|
||||
@@ -2032,7 +2277,8 @@ class Controller:
|
||||
self._mode_faults = now
|
||||
|
||||
def _pack_action(self, what, unit, value, now) -> dict:
|
||||
"""OPT-11: switch one battery on or off, or set its mode.
|
||||
"""OPT-11: switch one battery on or off, set its mode, or (P3) turn
|
||||
its selling on or off.
|
||||
|
||||
Store state, not an option, so it applies on the next cycle with no
|
||||
restart. Written to the action log. Switching a battery off also stops
|
||||
@@ -2056,6 +2302,13 @@ class Controller:
|
||||
if not value:
|
||||
self._maint_stop_off(self.maints[self.inverters.index(inv)],
|
||||
now)
|
||||
elif what == "pack_sell":
|
||||
if not isinstance(value, bool):
|
||||
return {"error": messages.pack_refused("value")}
|
||||
inv.store_set(packmode.SELL_KEY, value)
|
||||
self.log_event(f"{inv.name} selling switched "
|
||||
f"{'on' if value else 'off'} by operator",
|
||||
hint=messages.hint("pack_sell", unit=inv.name))
|
||||
else:
|
||||
if value not in packmode.MODES:
|
||||
return {"error": messages.pack_refused("value")}
|
||||
@@ -2064,7 +2317,10 @@ class Controller:
|
||||
f"by operator",
|
||||
hint=messages.hint("pack_mode", unit=inv.name))
|
||||
pm = self.pack_mode(inv)
|
||||
return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
|
||||
out = {"unit": inv.name, "use": pm.use, "mode": pm.mode}
|
||||
if what == "pack_sell":
|
||||
out["sell"] = pm.sell
|
||||
return out
|
||||
|
||||
def mode_values(self) -> dict:
|
||||
"""OPT-11 P2: each battery's mode and switch, for their HA state
|
||||
@@ -3579,15 +3835,20 @@ class Controller:
|
||||
# decision presented as current.
|
||||
self.arb, self.arb_at = None, now
|
||||
self.arb_headroom_w = None
|
||||
if arbitrage.enabled(self.o):
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
# OPT-11 P3: with battery modes on, each battery's mode decides who
|
||||
# trades (`_mode_roles()`, `_mode_trade()`, below, which read the
|
||||
# same headroom), and this site-wide trade is not run.
|
||||
site_trade = arbitrage.enabled(self.o) and not self.modes_on
|
||||
if site_trade or self.modes_on:
|
||||
# SAFETY-47. HOISTED OUT OF THE CALL, NOT COPIED. One evaluation of
|
||||
# the shipped expression feeds both the sizing below and
|
||||
# `arbitrage.sized_from_meter()` in `_shadow_settle()`, so the
|
||||
# caveat on the recorded watts can never be answered from a
|
||||
# different cycle's guard than the watts were.
|
||||
self.arb_headroom_w = peakguard.charge_headroom_w(self.peak_guard)
|
||||
if site_trade:
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct)
|
||||
self.arb = arbitrage.decide(
|
||||
self.fresh_tariff_plan(now), now,
|
||||
peak_guard_active=arbitrage.guard_state(self.peak_guard),
|
||||
@@ -3767,12 +4028,17 @@ class Controller:
|
||||
# apart from the hard limits, and it gives way to the peak guard's
|
||||
# site floor (P_PEAK) only as far as the floor needs, so the two never
|
||||
# meet in a `lo > hi` (arbiter._unit_modes()).
|
||||
# P3: the range is the switch's, narrowed by the battery's role in
|
||||
# the price runs this tick (`modetrade.decide()`); both hold 0 W.
|
||||
if self.modes_on:
|
||||
tick = self._mode_roles(now, maint_results)
|
||||
for inv, result in zip(self.inverters, maint_results):
|
||||
if result.phase != IDLE:
|
||||
continue
|
||||
pm = self.pack_mode(inv)
|
||||
lo, hi = packmode.mode_range(pm, inv.max_w)
|
||||
rlo, rhi = tick.ranges.get(inv.name, (lo, hi))
|
||||
lo, hi = max(lo, rlo), min(hi, rhi)
|
||||
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
|
||||
actuator=self.actuator_of("mode",
|
||||
inv)))
|
||||
@@ -3823,6 +4089,13 @@ class Controller:
|
||||
_ctl.REFUSED,
|
||||
actuator=self.actuator_of("refused",
|
||||
inv)))
|
||||
# --- OPT-11 P3: the site trade, after every unit claim --------------
|
||||
# Sized from what the batteries may place with their modes and hard
|
||||
# limits (the loop bound), so none is asked for watts it may not give.
|
||||
mode_trade, trade_was = False, self._mode_trade_was
|
||||
if self.modes_on:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self._mode_trade_was = mode_trade
|
||||
if (self.auto or self.shadow) and self.inputs_ok:
|
||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
@@ -3840,6 +4113,9 @@ class Controller:
|
||||
# stale period as power.
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
self.i_w = self.target
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
|
||||
if trade_was and not mode_trade:
|
||||
self._reseed_run_edge(claims)
|
||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||
# against the loop's own ask no longer describes the wire. A gap
|
||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||
@@ -4172,7 +4448,8 @@ class Controller:
|
||||
else:
|
||||
self.why_words = messages.why(
|
||||
_why, resolution.target_w, resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
contradiction=resolution.contradiction,
|
||||
raised=why_raised(resolution, claims))
|
||||
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
|
||||
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
||||
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
||||
@@ -5934,8 +6211,11 @@ class Controller:
|
||||
# instead of inferring it, and separates "trading is off" from "no
|
||||
# decision was reached this cycle"; `cycle_at` and `arb_at` are what
|
||||
# make the second one an observation rather than a guess.
|
||||
# OPT-11 P3: with battery modes on the modes trade, whatever the
|
||||
# switch says, so the row reports their decision.
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o), decision=self.arb,
|
||||
on=arbitrage.enabled(self.o) or self.modes_on,
|
||||
decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
@@ -5967,7 +6247,7 @@ class Controller:
|
||||
|
||||
async def handle_action(self, what: str, *, unit=None, value=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
if what in ("pack_use", "pack_mode"):
|
||||
if what in ("pack_use", "pack_mode", "pack_sell"):
|
||||
return self._pack_action(what, unit, value, now)
|
||||
if what == "auto_toggle":
|
||||
self.auto = not self.auto
|
||||
|
||||
Reference in New Issue
Block a user