Skip to content

Scheduler test plan

266 behaviours: 106 tested, 119 untested, 41 unclear. Risk: 110 high, 120 medium, 36 low.

For comparison, the Modbus Bridge's plan (its PR #217): 238 behaviours: 130 tested, 93 untested, 15 unclear. The row-by-row comparison is Scheduler parity.

The input for the Scheduler test phase. Every behaviour documented in User guide → Scheduler carries a hidden marker in the page source with its code location, its current test (or untested / unclear) and whether it can write to a battery. The table below is generated from those markers. The format and the columns are the Modbus Bridge's, so the two plans line up; the behaviour ids match where the two bridges document the same thing.

How to read it

Column Meaning
# Behaviour id. The prefix names the page: IDX overview, CON concepts, TRG triggers, CND/SEN conditions, ACT/HAA actions, TGT targets, EXT exit, PRI priorities, OUT outcomes, RST/CLK restarts and clock. An id shared with the Modbus Bridge's plan is the same topic; numbers above that plan's highest per prefix are Direct Connect only.
Risk high: can write to a battery and no test covers it (or the code is unclear). medium: can write to a battery but is tested, or cannot write but is untested or unclear. low: cannot write and is tested.
Code File and lines under src/franklinwh_direct_connect_bridge/.
Current test The unit test that asserts it, or untested / unclear (the code is ambiguous or looks wrong; the test decides). A behaviour is marked tested only when the named test was read and asserts it.
Proposed test unit: pytest against scheduler.py with the store in a temporary SQLite file and battery_control.execute monkeypatched, as in tests/test_scheduler.py. unit (API): FastAPI TestClient against the app. browser: Playwright against a throwaway bridge. VM/mock: a throwaway bridge (HAOS test VM or docker-compose.demo.yml) with a mock gateway.
Battery write Whether the behaviour decides if, when or how a force, release, operating-mode, reserve or off-grid command reaches a gateway.

The table is ordered by risk, then unclear before untested before tested.

Test safely

Battery-write tests run against a mock gateway or the Direct Connect library's Emulator on a throwaway bridge, never a real aGate and never the owner's live bridge. A force command in this bridge goes through the Modbus client (this bridge) to the Modbus TCP Server (aGate :502); a test of that path needs the FranklinWH Gateway Emulator or a monkeypatched battery_control.execute, as the existing tests do. unclear rows marked as likely defects should be written as tests that state the expected behaviour and fail today; fixes come after.

Regenerate

python docs/dev/_gen_test_plan.py          # rewrite the counts and the table below
python docs/dev/_gen_test_plan.py --check  # non-zero if out of date or a marker is malformed

Marker format, one HTML comment next to the documented text:

<!-- @b TRG-04 ;; src: scheduler.py:314-321 ;; tested: tests/test_scheduler.py::test_trigger_window_multiple_windows ;; write: yes ;; b: what the code does ;; test: the test to add -->

Behaviours

266 behaviours: 106 tested, 119 untested, 41 unclear. Risk: 110 high, 120 medium, 36 low.

# Risk Behaviour Page Code Current test Proposed test Battery write
ACT-04 high An empty power is 0 (pct 0 or 0 W), not the full rating; only the form seeds 100 % actions scheduler.py:1552-1558, static/js/scheduler_tab.js:490-497 unclear unit: force with no power sends 0 W; decide whether empty should mean full rating yes
ACT-10 high A power in W or kW is not capped by the bridge at the 702 rating actions battery_control.py:524-528, scheduler.py:1553-1556 unclear unit: power_w above max_ch is passed to BatteryCommand unchanged; decide whether to cap yes
ACT-13 high A force refused for capability reasons is classified OK, logged fired and marked done for the occurrence actions scheduler.py:1632-1646, scheduler.py:1598, battery_control.py:496-500 unclear unit: run_action_outcome with available() false must not return Verdict.OK yes
ACT-24 high set_mode and offgrid results with ok False are classified OK because the text lacks the failure words actions scheduler.py:1785-1799, scheduler.py:1632-1646 unclear unit: client.set_mode returning ok False must give Verdict.FAILED yes
ACT-39 high battery_control holds a single _active dispatch and a global _soc_getter; a second force ends the first watchdog actions battery_control.py:20-26, battery_control.py:113-124, battery_control.py:487-491 unclear unit: two execute() calls for two hosts; assert both watchdogs stay armed (fails today) yes
ACT-41 high Verdict UNKNOWN is unreachable for scheduler actions because failures are raised as ConnectionError actions scheduler.py:1655-1667, scheduler.py:1854-1855, resilience.py:70-80 unclear unit: a run_action that times out after the request left should yield UNKNOWN through run_action_outcome yes
ACT-42 high With no window end the deadline falls back to fire_at+duration today, which is often past, so resilience gets 1 s actions scheduler.py:2411-2413, scheduler.py:273-279, scheduler.py:1652-1654 unclear unit: always entry with a failing force; assert number of attempts yes
CLK-04 high The offset is fixed per read and cached up to 10 min, so a DST change applies late; date keys stop a daily re-fire in the repeated hour, slot keys may not restarts-and-clock site_time.py:22-25, site_time.py:97-107, scheduler.py:385-391, scheduler.py:332-366 unclear unit: simulate offset change across a cached read; assert window timing; VM: observe on a change day yes
EXT-05 high The exit edge depends only on the window having been open last tick, not on the entry having fired or owning the force, so a gated or superseded force entry still sends Release and exit HA actions run exit-and-conflicts scheduler.py:1995-2038, scheduler.py:1544-1546, battery_control.py:510-517 unclear unit: a gated force entry over a whole window, assert no Release at its end (fails today); a second entry's force active, assert the first entry's window end does not release it yes
EXT-20 high On shutdown the in-memory force is released to FWH_MODBUS_HOST or the first registered gateway's host, not the host the force was sent to exit-and-conflicts app.py:1190-1206 unclear unit (API): two gateways, force on the second, shut down the app, assert the Release goes to the second host (fails today) yes
EXT-21 high DELETE removes the row; the next tick does not load it, so no exit edge runs and a running force is not released exit-and-conflicts scheduler.py:1973-2038, app.py:3134-3145 unclear unit (API): fire a force, DELETE the schedule, tick, assert a Release is sent (fails today) yes
EXT-24 high effective_inside goes true again when exit conditions stop holding, so each later true edge re-runs the exit steps including a force Release exit-and-conflicts scheduler.py:2006-2014, scheduler.py:2039-2043 unclear unit: force fired, exit true, exit false, exit true; assert one Release only (fails today) yes
EXT-25 high The watchdog duration_s is duration_min*60 for every trigger type; a window trigger keeps the hidden form default of 90 min exit-and-conflicts scheduler.py:2409-2413, scheduler.py:1577-1581, static/js/scheduler_tab.js:244 unclear unit: window 17:00-20:00 with duration_min 90, assert duration_s 10800 (fails today, #160) yes
IDX-09 high PUT without enabled re-enables a disabled schedule (request default True) index app.py:631, app.py:3124-3132 unclear unit (API): PUT {name} on a disabled schedule; assert still disabled (fails today) yes
IDX-12 high Pause finds only pending/failed runs, and the tick does not skip a paused run, so pause neither holds a running force nor stops retries index app.py:3169-3204, db.py:2133-2146, scheduler.py:2147-2156 unclear unit: pause a failed run, tick inside the window; assert no attempt (fails today) yes
OUT-24 high Stop releases via the default gateway's Modbus host when the schedule has an active dispatches row, ends the rows stopped and logs stopped; no exit HA actions, no restore outcomes app.py:3147-3167 unclear unit (API): active row for the entry on gw2; POST /stop; assert the Release goes to gw2's host (fails today on multi-gateway) yes
OUT-28 high The UI appends the top-bar gateway to /run and the endpoint prefers it over the entry's gateway_id outcomes static/js/scheduler_tab.js:616-631, static/js/app.js:880-885, app.py:3435-3436 unclear unit (API): entry bound to gw2, POST /run?gateway=gw1, assert it runs on gw2 (fails today) yes
OUT-32 high run_action never raises and _attempt raises ConnectionError, which _arrived treats as not arrived, so UNKNOWN cannot come out of the scheduler's action path outcomes scheduler.py:1655-1667, resilience.py:70-80, scheduler.py:1854-1855 unclear unit: make the force write time out after sending; assert verdict UNKNOWN (fails today) yes
OUT-33 high A failing device write is retried inside resilience.call (DISPATCH 5 attempts, WRITE 2) with sleeps, inside the scheduler's tick thread outcomes scheduler.py:1648-1679, resilience.py:97-109, resilience.py:160-211 unclear unit: run_action fails every time for a force with a 10-minute window; assert attempts and total time; assert other gateways still tick yes
OUT-34 high _classify reads OK unless the text contains failed/error/not confirmed, so a force refused for capability and a mode/offgrid with a non-zero result are logged fired outcomes scheduler.py:1632-1646, scheduler.py:1598, scheduler.py:1785-1799, battery_control.py:496-508 unclear unit: run_action_outcome for a force with the Modbus client off, and set_mode returning ok False; assert FAILED (fails today) yes
OUT-36 high Pause only finds pending/failed runs (never a succeeded one) and the tick does not skip paused runs, so a paused run is attempted again outcomes app.py:3169-3203, db.py:2133-2146, scheduler.py:2147-2156 unclear unit: pause a failed run, tick inside the window, assert no attempt (fails today); pause after a successful force, assert it is released (fails today) yes
RST-04 high release_stale is defined but never called; start-up relies on the reconcile policy; pollers, the reconcile thread and the scheduler worker start independently restarts-and-clock battery_control.py:133-144, app.py:1180-1182 unclear unit: decide whether start-up should release a stale force; assert it yes
RST-08 high Boot reconcile and shutdown release use FWH_MODBUS_HOST or the first gateway's host only restarts-and-clock app.py:975-976, app.py:1004-1018, app.py:1196-1204 unclear unit: two gateways with active dispatch rows; assert both reconciled (fails today) yes
TGT-07 high A gateway skipped as stale gets no exit edge; only the in-memory watchdog can still end its force targets scheduler_worker.py:156-160, scheduler.py:2014-2033, battery_control.py:92-110 unclear unit: force running, then gateway stale past window end; assert a release is still sent (fails today) yes
TGT-09 high The UI's Run now passes the top-bar gateway, which overrides the schedule's gateway_id targets static/js/scheduler_tab.js:616-631, static/js/app.js:880-885, app.py:3433-3436 unclear unit (API): POST /run?gateway=gw1 on a schedule bound to gw2 should act on gw2 or refuse yes
TGT-17 high The default-gateway test ignores enabled, so a disabled first gateway leaves unbound schedules unevaluated targets scheduler.py:1858-1866, scheduler_worker.py:96-97 unclear unit: first gateway disabled, second enabled; unbound schedule never fires (fails today) yes
TGT-20 high Stop, Pause, shutdown release and boot reconcile use the default gateway host, not the dispatch's host targets app.py:3148-3174, app.py:3177-3204, app.py:3473-3480, app.py:1196-1204, app.py:973-977, app.py:1004-1023 unclear unit (API): dispatch row on host B, Stop calls execute("Release", host=B) (fails today) yes
TRG-09 high A window with start equal to end is read as 24 hours and is always open triggers scheduler.py:307 unclear unit: decide whether start == end should match nothing (Modbus Bridge) or 24 h, then assert it yes
TRG-19 high An interval of 1440 min or more opens daily at the anchor, but next_fire_ts reports anchor + interval triggers scheduler.py:332-347, scheduler.py:467-476 unclear unit: 1500-min interval anchored 06:00: window_now and next_fire_ts agree yes
TRG-28 high An always entry's occurrence key is the constant 'always'; once it finishes ok it is terminal, so the entry never fires again triggers scheduler.py:378-379, scheduler.py:2147-2150, db.py:1935-1962 unclear unit: tick an always force entry through two rising edges; assert a second Force (fails today) yes
TRG-29 high The fired-once mark for always is a stored occurrence row per schedule and gateway; it survives restart, edit and disable, and prune_occurrences is never called triggers db.py:1935-1962, db.py:2202-2212 unclear unit: after restart (fresh in-memory state, same DB) the always entry still does not fire yes
TRG-34 high A window entry keeps a hidden duration_min (default 90) that becomes the force's duration_s, so a force stops before a longer window ends triggers static/js/scheduler_tab.js:243-250, static/js/scheduler_tab.js:656, scheduler.py:2409-2413, scheduler.py:1578-1581 unclear unit: window 17:00-20:00 force; assert duration_s covers the window (fails today: 5400) yes
TRG-51 high A once entry ignores its windows list (only window type reads windows) and with no start_date it fires every day triggers scheduler.py:297-311, templates/tabs/scheduler.html:435-464, scheduler.py:430-442 unclear unit: once with windows [18:00-19:00] and fire_at 06:00 is open at 06:00, not 18:30; once with no date is due daily yes
ACT-02 high POST and PUT /api/schedules accept any action dict with no validation actions app.py:629-688, app.py:3088-3103 untested unit (API): POST an unknown action kind and a set_mode without mode; assert 201 today, decide whether to refuse yes
ACT-03 high pct power = rating for the direction x pct/100; the rating falls back to 5000 W when discovery fails actions battery_control.py:437-443, battery_control.py:519-528 untested unit: fake controller with RATED_MAX_CHARGE_W 8000 and a failing discover; assert 50 % -> 4000 W and 2500 W yes
ACT-05 high The form offers pct and kW; the engine also accepts w; any other unit is read as pct actions static/js/scheduler_tab.js:474-476, scheduler.py:1551-1558 untested unit: _force_dispatch with unit kw, w and pct passes the right power_w/power_pct/power_mode to execute yes
ACT-14 high A force connects, reads ratings, sends one BatteryCommand, arms the watchdog, logs dispatch-start and records the dispatch row actions battery_control.py:505-543, scheduler.py:1578-1597 untested unit: fake controller; assert send_command power, _arm_watchdog args and a dispatches row with signed watts yes
ACT-15 high The bridge's own watchdog (5 s loop) releases the force; no duration is handed to the library actions battery_control.py:1-8, battery_control.py:92-110, battery_control.py:532-536 untested unit: arm with duration 10 s and a fake clock; assert _release_now after 10 s yes
ACT-17 high The force time limit is duration_min x 60 for every trigger type, enforced by the watchdog actions scheduler.py:2409-2413, scheduler.py:1578-1581, battery_control.py:99-100 untested unit: tick a window-trigger force entry; assert execute duration_s equals the window length (fails today, #160) yes
ACT-18 high target_soc releases a charge at SoC>=target and a discharge at SoC<=target every 5 s from the poller cache; 0 = off actions battery_control.py:101-110, scheduler.py:1560-1565, scheduler.py:1577 untested unit: arm with watts>0, target 80, soc_getter returning 81; assert release yes
ACT-19 high The form sends the whole action object, so API-only keys in it survive a form save actions static/js/scheduler_tab.js:273, static/js/scheduler_tab.js:556-561, app.py:3111-3127 untested unit (API): PUT with the form's body keeps action keys the form does not show yes
ACT-20 high Standby is BatteryCommand(0 W) after the same capability check actions battery_control.py:519-522, scheduler.py:1522-1525 untested unit: execute Force Standby sends power_watts 0 yes
ACT-23 high A set_mode without mode raises KeyError, returned as "set_mode failed" and retried actions scheduler.py:1785-1787, scheduler.py:1854-1855 untested unit: run_action_outcome set_mode with no mode is FAILED yes
ACT-26 high The If busy check runs only for force winners; set_mode and reserve_soc still contend as the battery resource in the same tick actions scheduler.py:2126-2140, scheduler.py:2183-2190 untested unit: active dispatch owned by A, set_mode entry B with conflict defer; assert set_mode is sent yes
ACT-30 high execute() refuses every force unless capability() confirms a SunSpec read (state ready) actions battery_control.py:377-402, battery_control.py:492-504 untested unit: capability not ready returns ok False with the state and never calls send_command yes
ACT-35 high offgrid sends set_offgrid(on, soc default 5) with no confirm step actions scheduler.py:47-51, scheduler.py:1796-1799, client.py:383-389 untested unit: offgrid action calls set_offgrid with on and soc yes
ACT-40 high A failing device write is retried inside run_action_outcome (force: DISPATCH, 5 attempts; others: WRITE, 2) and blocks the tick actions scheduler.py:1648-1679, resilience.py:97-109, resilience.py:160-211, scheduler_worker.py:206 untested unit: run_action_outcome with a failing run_action and patched sleep; assert 5 attempts for force and 2 for set_mode yes
CND-03 high tick builds one snapshot per gateway from last_state plus HA, solar, NEM, HA price, tariff, billing, fixed, system, const, ratings and derived sensors; ha_snapshot GETs /api/states per instance each tick conditions scheduler.py:1947-1970, scheduler.py:959-986 untested unit: tick with two gateways and different last_state; assert each entry is evaluated on its own gateway's values yes
CND-04 high on/off gateway sensors are int 1/0; _eq compares exactly when either side is a bool, so a bool equals 1 but not the text "1" conditions scheduler.py:108-116, scheduler.py:911-919 untested unit: evaluate status.vpp == 1 and == "1"; a True live value against "1" yes
CND-06 high == and != compare numerically when both sides are numbers, exactly for bools, otherwise case-insensitive text (differs from the Modbus Bridge, which compares text exactly) conditions scheduler.py:108-116, scheduler.py:164-167 untested unit: mode.name == "SELF-CONSUMPTION" true; 25 == "25.0" true; != of the same yes
CND-07 high between is inclusive and order-independent; false if any side is not numeric conditions scheduler.py:173-178 untested unit: 25 between 30,20 true; 20 and 30 included; missing value2 false yes
CND-08 high in / not_in take numbers and inclusive ranges a-b, a..b or en/em dash, split on comma, semicolon or newline; text members are dropped conditions scheduler.py:119-138, scheduler.py:179-182 untested unit: 25 in "10, 20-30" true; -5..-1; range order reversed yes
CND-09 high an empty member list matches nothing: in is false, not_in is true conditions scheduler.py:179-182 untested unit: in "" false, not_in "" true yes
CND-10 high matchlist compares whole case-folded text values with no range parsing conditions scheduler.py:141-146, scheduler.py:183-185 untested unit: Self-Consumption matchlist "self-consumption, Time of Use" true yes
CND-11 high like uses * or % for many and ? or _ for one, case-insensitive; with no wildcard it is a substring test conditions scheduler.py:149-156, scheduler.py:186-188 untested unit: like "self%", "consum", "2?" yes
CND-12 high matchlist and like compare str(live), so a number or bool is matched as its text form conditions scheduler.py:149-156, scheduler.py:183-188 untested unit: 25 like "2?" true; 63.0 matchlist "63" false yes
CND-14 high value_kind sensor compares with snapshot[value_sensor]; a None RHS makes the row false conditions scheduler.py:219-223 untested unit: soc 25 > Lookup b=20 true; Lookup of a missing sensor false yes
CND-19 high the form prunes empty groups and saves [] for an empty block; evaluate([]) is true (no gate) and an empty exit list never exits conditions static/js/scheduler_tab.js:878-899, scheduler.py:260-261, scheduler.py:2006-2007 untested browser: save a block with an empty group; assert the stored list yes
CND-20 high an API exit list holding an empty ALL group is true, so due-ok entries are refused with exit condition already holds conditions scheduler.py:241-248, scheduler.py:2006-2009, scheduler.py:2046-2048 untested unit: tick with exit_conditions [{match: all, conditions: []}]; assert nothing fires yes
CND-21 high tick_once calls tick per eligible gateway with that gateway's last_state, host and id; gateway_scope all entries are evaluated on each conditions scheduler.py:1934-1970, scheduler_worker.py:107-116, scheduler_worker.py:156-163 untested unit: two gateways, scope all, SoC 30 vs 80 and a > 50 gate; only one fires yes
CND-23 high with entry_hold_s > 0 the entry is held back until now minus the first due tick reaches the hold conditions scheduler.py:2062-2073 untested unit: tick at t, t+60, t+300 with hold 300; fires only on the last yes
CND-24 high any not-due tick pops the dwell timer conditions scheduler.py:2052-2053 untested unit: gate true, false, true; assert the hold restarts yes
CND-25 high outside the window due() is false, so the dwell timer is reset at window exit conditions scheduler.py:2045, scheduler.py:2052-2053 untested unit: hold spanning window end does not carry into the next window yes
CND-26 high entry_hold_s 0 skips the hold; no upper bound is enforced conditions scheduler.py:2063-2064 untested unit: hold 0 fires on the first tick yes
CND-27 high the dwell timer is popped when the entry becomes ready; a failed run is not marked fired, so the next due tick starts a new hold (differs from the Modbus Bridge, which skips the hold on a retry) conditions scheduler.py:2065-2073, scheduler.py:2459-2466 untested unit: hold 60, failing action; assert the second attempt is at least 60 s after it is due again yes
CND-28 high due() returns already fired this occurrence before it evaluates the conditions conditions scheduler.py:538-544 untested unit: force fired, then the gate turns false; assert no release until the window or exit ends it yes
CND-36 high exit conditions are evaluated every tick while the window is time-inside, for any action kind, not only while a force runs conditions scheduler.py:2006-2009 untested unit: a set_mode entry with an exit condition; assert the exit edge is logged and exit HA actions run yes
CON-05 high Saving does not trigger a tick; the next regular tick reads the changed schedule from the database concepts scheduler.py:1973, app.py:3089-3134 untested unit (API): create an open-window entry; assert nothing is sent until tick_once runs yes
CON-06 high The scheduler worker always starts with the bridge; there is no module switch concepts app.py:1180-1182 untested unit: app lifespan starts scheduler_worker.run yes
CON-10 high Hold timers, last-tick window state and gated-day marks are in memory; last_fired_day, occurrences and dispatches are stored concepts scheduler.py:1869-1884, db.py:920-931 untested unit: reset the module dicts (restart) with an entry fired today; assert no re-fire and no exit edge yes
EXT-04 high Disabling an entry makes time_inside false, so the next tick runs the exit edge and releases a force; re-enabling in the same occurrence does not re-fire exit-and-conflicts scheduler.py:1997-2014, scheduler.py:2030-2033 untested unit: fire a force, set enabled false, tick, assert Release and an exit row; re-enable, tick, assert nothing is sent yes
EXT-09 high Exit conditions are evaluated whenever the window is time-inside, for every action kind (also set_mode and HA-only), not only for a running force exit-and-conflicts scheduler.py:2006-2009 untested unit: a set_mode entry with an exit tree; fire, make exit true, assert an exit row 'exit condition met' yes
EXT-12 high At the exit edge restore_on_exit writes the captured mode back with set_mode unless the snapshot already shows it exit-and-conflicts scheduler.py:2023-2029, scheduler.py:2257-2280 untested unit: set_mode entry with restore_on_exit, capture Self-Consumption, fire TOU, close the window, assert set_mode('Self-Consumption') yes
EXT-17 high Release calls reset_control_state, clears the in-memory dispatch, fires dispatch-end if one was held, never reads back, and is refused when the Modbus client is off exit-and-conflicts battery_control.py:496-517 untested unit: fake controller; assert reset_control_state called and the result; available() false returns ok false (#174) yes
EXT-26 high The watchdog releases at duration_s or when SoC crosses target_soc in the force's direction, checked every 5 s from the poller's cached SoC exit-and-conflicts battery_control.py:92-110, scheduler.py:1560-1565 untested unit: arm the watchdog with a fake connect and soc_getter, assert release on duration and on target yes
IDX-03 high Saving an active schedule asks to confirm; the running force is not re-sent because the occurrence is already fired index static/js/scheduler_tab.js:545-553, scheduler.py:538-541 untested unit: change power on a fired, open-window entry; assert no second Force this occurrence yes
IDX-04 high A template loads into the form with enabled false and is not saved until Create Entry index static/js/scheduler_tab.js:157-179, templates/tabs/scheduler.html:312-342 untested browser: Use a template, cancel; assert nothing created yes
OUT-06 high A loser's open run (pending or failed) is finished skipped with the winner named outcomes scheduler.py:2296-2314 untested unit: a failed run, next tick a higher-priority entry ready together; assert the run becomes skipped yes
OUT-20 high Run now sends the battery action and the fire-phase HA actions (guards honoured) without checking the window or entry conditions, and logs manual outcomes app.py:3424-3455 untested unit (API): an entry whose conditions fail; POST /run; assert the action is sent and a manual row is written yes
OUT-21 high Run now passes duration_min as the force's time limit, 0 meaning none outcomes app.py:3441-3448 untested unit (API): duration_min 0 and 30; assert duration_s 0 and 1800 yes
OUT-22 high Run now does not check active dispatches; it supersedes another force outcomes app.py:3424-3455 untested unit (API): an active _widget row; POST /run; assert the force is sent yes
OUT-23 high Run now ignores the trigger window, the calendar and the fired mark, and works on a disabled entry outcomes app.py:3424-3455 untested unit (API): disabled entry outside its window; POST /run sends yes
OUT-27 high A Run now force records a dispatches row with the schedule's id, so Stop and If busy see it outcomes app.py:3441-3448, scheduler.py:1582-1597, app.py:3157 untested unit (API): POST /run then POST /stop; assert stopped true yes
PRI-03 high There is no incumbent and no cross-tick priority: a later-ready entry is judged only by If busy against active force rows, and non-force entries are not judged at all priorities scheduler.py:2078-2140 untested unit: a running priority-1 force, a priority-9 force ready a tick later with defer, assert it defers; with override assert it sends yes
PRI-04 high Ties are broken by created_at ascending (then id), not by name priorities scheduler.py:2283-2293 untested unit: two equal-priority force entries ready on one tick, the older created_at wins; rename one, same winner yes
PRI-10 high defer gives up for the day; wait re-tries every tick and fires once the other dispatch row is closed priorities scheduler.py:2130-2138 untested unit: busy row, wait entry ticks waiting, close the row, next tick sends yes
PRI-11 high Only rows in the bridge's own dispatches table count as busy; a force from another Modbus client or a VPP does not, so entries dispatch over it priorities scheduler.py:2121-2128 untested unit: no dispatch row but the battery reports a force, assert a defer entry still sends (documents today's behaviour) yes
RST-01 high Graceful shutdown releases the in-memory active force via FWH_MODBUS_HOST or gateways()[0].active_host, ignoring the host the force was sent to restarts-and-clock app.py:1190-1206 untested unit: lifespan exit with an active force on gateway 2; assert Release goes to gateway 2's host (fails today) yes
RST-07 high After a restart the duration and target-SoC timer is gone; with policy notify only a later window-end edge (if the bridge is up for it) releases restarts-and-clock battery_control.py:20-27, scheduler.py:1995-2039, battery_control.py:207-212 untested unit: restart mid-window (clear module state, record_dispatch active); tick to window end; assert Release; tick a window that ended during downtime; assert none yes
SEN-09 high tariff.import_billable is int(billable) from rate_model.resolve, None when billable is None conditions scheduler.py:1507-1515 untested unit: a free window and a priced window yes
SEN-13 high weather.* and solar_forecast.* come from the cached Open-Meteo forecast and are absent without a latitude and longitude conditions scheduler.py:1185-1198, solar_forecast.py:111-140 untested unit: solar_snapshot with no location returns {} yes
SEN-19 high the NEM price is cached 240 s and the last good value is kept on a fetch error with no age limit; an HA price entity then an active dynamic tariff override it conditions aemo_nem.py:19, aemo_nem.py:29-47, scheduler.py:1950-1957 untested unit: spot_price after a failed fetch an hour later returns None (expected) yes
TGT-02 high gateway_scope all makes every eligible gateway (mocks included) evaluate the schedule on its own tick targets scheduler.py:1979-1988, app.py:640-644, scheduler_worker.py:156-163 untested unit: scope all schedule, tick gw1 and gw2; assert one fire per gateway yes
TGT-05 high The window-end release runs per gateway:entry key, to that gateway's host targets scheduler.py:1989, scheduler.py:2014-2033 untested unit: all-scope force on two gateways; close the window; assert a Release to each host yes
TGT-10 high An unbound schedule runs only when the tick's gateway is state.gateways()[0] targets scheduler.py:1858-1866, scheduler.py:1987-1988, state.py:84-94 untested unit: two registered gateways, unbound schedule; assert it fires only on the first yes
TGT-11 high An unbound schedule claims /battery, the same resource as one bound to that gateway targets scheduler.py:2088-2089 untested unit: unbound and bound force entries on gw1 in one tick; assert one deferred yes
TGT-22 high A gateway_id matching no registered gateway is skipped on every tick with no log targets scheduler.py:1984-1986 untested unit: schedule bound to "ghost"; tick two real gateways; assert no fire and decide on a warning yes
TRG-02 high A duration of 0 opens the window for the one Fire at minute (one minute for interval and cron); a force sent then has no timer and is released at the next tick after the minute triggers scheduler.py:314-317, scheduler.py:332-366, scheduler.py:2409-2413, scheduler.py:2011-2033 untested unit: tick a duration-0 force entry at hh:mm:10 and hh:(mm+1):10; assert Force then Release yes
TRG-04 high A window is open from start up to but not including end triggers scheduler.py:314-321 untested unit: window_now at the end minute is outside yes
TRG-07 high A window whose end is at or before its start wraps past midnight triggers scheduler.py:307, scheduler.py:318-321 untested unit: window_now for a 22:00-06:00 window at 23:00 and 01:00 (the existing midnight tests use window_state, which only /test uses) yes
TRG-08 high For an overnight window the calendar is checked against the date the window opened, so the after-midnight part belongs to the previous day triggers scheduler.py:324-329, scheduler.py:385-391, scheduler.py:1997-1998 untested unit: Wed-only 22:00-02:00 due at Thu 01:00 true and Wed 01:00 false (the existing test covers _window_anchor_date, which nothing calls) yes
TRG-10 high Times parse as h*60+m, so 24:00 is minute 1440; unparsable windows are dropped and an empty list falls back to fire_at + duration_min triggers scheduler.py:265-270, scheduler.py:301-311 untested unit: 18:00-24:00 open at 23:59; window list [] falls back to fire_at yes
TRG-13 high An overnight one-off runs past midnight into the next date, attributed to its own date triggers scheduler.py:324-329, scheduler.py:385-391 untested unit: once 22:00+240 on D, due at D+1 01:00 is true yes
TRG-15 high duration_min has no upper bound in the API (the form input has max 1440) triggers templates/tabs/scheduler.html:462-463, app.py:633 untested unit: a 2000-minute daily window: decide and assert how occurrences overlap yes
TRG-18 high The interval series restarts at the anchor each day, so a period that does not divide 24 h has a short final gap triggers scheduler.py:338-343 untested unit: 25-minute interval open at 23:45 and 00:00 yes
TRG-22 high day_of_month and months are not range-checked; 0 or null day_of_month is no restriction triggers app.py:656-657, scheduler.py:449-454 untested unit (API): POST day_of_month 0 and months [13]; decide whether to reject yes
TRG-23 high A monthly trigger with no day_of_month has no day restriction and fires every day triggers scheduler.py:449-454 untested unit: monthly with day_of_month None due on any day yes
TRG-25 high Cron day-of-week follows croniter (0 = Sunday), unlike days (0 = Monday) triggers scheduler.py:355-358, scheduler.py:444 untested unit: '0 18 * * 0' open on a Sunday 18:05, closed Monday yes
TRG-49 high The tick applies the calendar restrictions (days, months, day of month, date range) to every trigger type, interval, cron and always included triggers scheduler.py:426-455, scheduler.py:1997-2004 untested unit: interval entry with days [0] is not due on a Tuesday; always entry with an end_date in the past never fires yes
TRG-54 high There is no late fire: a missed window is only logged, never run afterwards triggers scheduler_worker.py:119-145, db.py:2102-2131 untested unit: window closes while the gateway is stale; assert no action after it reopens yes
CLK-02 medium next_fire, active_now, the timeline and /test use the container's naive clock; History uses the host zone restarts-and-clock app.py:3033-3038, app.py:3334, app.py:3386, static/js/scheduler_tab.js:81 unclear unit (API): container and site zones apart; assert the timeline and next_fire follow the site no
CND-31 medium Test against live uses get_state() (default gateway) and the container clock whatever the schedule's gateway, ignores exit conditions, hold and conflicts, and lists only top-level rows (a group row shows no sensor) conditions app.py:3360-3394 unclear unit (API): a schedule bound to a second gateway; assert the snapshot is that gateway's no
HAA-06 medium Exit-phase HA actions run at every closing edge of the effective window, fired or not actions scheduler.py:2011-2038 unclear unit: gated window with an exit notify; assert no send (fails today) no
HAA-07 medium Fire-phase HA actions run after a failed battery action and on every retry actions scheduler.py:2408-2417, scheduler.py:2459-2466 unclear unit: failing force plus notify, two retries; assert one notify (fails today) no
OUT-18 medium run_action_outcome is called without a notifier, so failures and unknowns of scheduled actions notify nobody outcomes scheduler.py:2409-2413, resilience.py:243-249 unclear unit: a failing force with a notifier in place; assert one 'action failed' notification (fails today, #172) no
OUT-35 medium resilience calls audit(action, detail=, result=, ok=) but _audit(**kw) takes no positional, so the TypeError is swallowed and nothing is logged outcomes resilience.py:237-241, scheduler.py:1699-1714 unclear unit: call the sink as resilience does; assert a schedule_log row (fails today) no
PRI-19 medium defer marks last_fired_day with the date, which matches only date-keyed occurrences, so interval, cron and window #n entries are deferred and logged every tick priorities scheduler.py:2131, scheduler.py:540 unclear unit: interval force entry with defer and a busy row, two ticks, assert one deferred row (fails today) no
TRG-52 medium next_fire and active_now are computed on the container's naive clock, not the site clock the tick uses triggers app.py:3033-3038, static/js/scheduler_tab.js:510-529 unclear unit (API): container UTC, site UTC-5; assert next_fire matches the site window no
ACT-28 medium Run now runs the fire-phase HA actions of any schedule, including HA-only ones actions app.py:3441-3455 untested unit (API): Run now on an HA-only schedule calls the notify once and logs manual no
ACT-38 medium The force option is disabled in the form with the reason when battery_control.available() is false actions app.py:3031-3046, templates/tabs/scheduler.html:552-564 untested browser: with the Modbus client off, the force option is disabled and the reason shown no
CND-02 medium an unknown sensor reads None and an unknown operator returns False; validate_import checks name, fire_at, dates, action, gateway and notify device but not sensors or operators conditions scheduler.py:189, scheduler.py:217-218, scheduler.py:824-861 untested unit: op '~' is false; validate_import of an unknown sensor has no warning no
CND-13 medium _buildTree sends number-looking values as numbers, the RHS of in/not_in/matchlist/not_matchlist/like/not_like as raw text, and value2 only for between conditions static/js/scheduler_tab.js:871-899 untested browser: save a row with "50" and with "10,20"; inspect the PUT body no
CND-15 medium Explain renders a Lookup as the quoted sensor label; the Test against live row shows r.value, which a Lookup row does not have conditions static/js/scheduler_tab.js:750-758, templates/tabs/scheduler.html:155-162 untested browser: a Lookup row in Explain and in Test against live no
CND-18 medium _eval_tree evaluates every node before combining and appends one trace row per leaf with sensor, op, value, live_value, result and cid conditions scheduler.py:241-248, scheduler.py:227-237 untested unit: trace length equals leaf count for an ALL whose first leaf fails no
CND-29 medium a gated reason is logged once per calendar day per gateway:entry with up to two failing leaves and a +N more suffix conditions scheduler.py:2054-2060, scheduler.py:1887-1903 untested unit: three failing leaves; assert one gated row with two leaves and +1 more across two ticks no
CND-32 medium neither evaluate nor test calls _guard_writes; the bridge's HTTP API has no login conditions app.py:3397-3399, app.py:3360-3362 untested unit (API): evaluate answers 200 without any credential no
CND-35 medium a disabled or removed instance contributes no values; validate_import does not check ha: sensors conditions scheduler.py:974-985, scheduler.py:824-861 untested unit: disabled instance; assert its ids are absent no
CON-02 medium The tick reads all schedules ordered by name; enabled is checked in due(); priority only applies among entries ready on the same tick concepts db.py:871-875, scheduler.py:530-531, scheduler.py:2078-2118 untested unit: two ready entries named so name order and priority order differ; assert the priority winner no
DEP-01 medium scheduler.py and scheduler_worker.py import nothing from MQTT; schedules run with MQTT off dependencies scheduler.py:1-30, scheduler_worker.py:20-30 untested unit: run tick_once with mqtt_enabled false and no broker; assert an entry fires no
HAA-02 medium The form saves every HA action row, complete or not actions static/js/scheduler_tab.js:419-441 untested browser: save a notify row with no message; assert it is stored no
HAA-03 medium Fire-phase HA actions run inside _fire_entry after the battery action actions scheduler.py:2397-2458 untested unit: two ticks inside one window; assert one notify no
HAA-04 medium Fire-phase HA actions run only from _fire_entry, after the gate and hold passed actions scheduler.py:2045-2076, scheduler.py:2415-2416 untested unit: gated entry with a notify action; assert no send no
HAA-05 medium An HA action runs in a phase when its when is that phase or both (default fire) actions scheduler.py:1918-1931 untested unit: _fire_ha_phase with when both runs in fire and in exit; exit-only does not run in fire no
HAA-08 medium A guard is one leaf or a tree; false or missing data skips the action without a log line actions scheduler.py:1907-1915, scheduler.py:1926-1927, static/js/scheduler_tab.js:431-438 untested unit: guard false skips the call; guard tree with match any works no
HAA-10 medium The placeholder pattern allows letters, digits, _ and dots, not colons actions scheduler.py:191 untested unit: substitute("%ha:i1:sensor.x%") is left unchanged today no
HAA-12 medium HA results are joined into the fired, exit or manual row; there is no ha_action status actions scheduler.py:2414-2417, scheduler.py:2034-2038, app.py:3453-3454 untested unit: entry with two HA actions logs one fired row containing both results no
HAA-13 medium run_action_outcome is called without a notifier, so no failure notification is sent actions scheduler.py:2409-2413, resilience.py:243-249 untested unit: failing scheduled force with a notifier configured; assert one notification (fails today) no
HAA-18 medium kind notify sends to one saved device, skipping a missing, disabled or instance-less device actions scheduler.py:58-62, scheduler.py:1801-1816, scheduler.py:2189 untested unit: notify with a disabled device returns "switched off" and sends nothing no
IDX-01 medium The list shows all schedules by default; All/Enabled/Disabled filter it and a note counts the hidden ones index static/js/scheduler_tab.js:20, static/js/scheduler_tab.js:500-507 untested browser: load the tab with one disabled schedule; assert it is shown no
IDX-02 medium Duplicate opens an unsaved form copy named 'Copy of …' with enabled false and no id index static/js/scheduler_tab.js:309-315 untested browser: duplicate, cancel; assert no new schedule no
IDX-07 medium A one-off is drawn only when today is its date (calendar check) index scheduler.py:874-875 untested unit: timeline_segments for a once entry on another date is empty no
IDX-11 medium The timeline ignores the gateway for segments and markers and uses the container clock index app.py:3328-3358 untested unit (API): two gateways' schedules; ?gateway=gw2 still returns both no
OUT-01 medium Create logs created, update logs enabled/disabled ('toggled') or updated ('settings changed'), delete logs deleted; import logs nothing; no field list outcomes app.py:3095-3103, app.py:3124-3132, app.py:3140-3144, app.py:3295-3309 untested unit (API): create, toggle, edit, delete, import; assert the rows and that import writes none no
OUT-03 medium The History view fetches 200 rows, offers one chip per status present plus a battery chip for _batt and _vpp rows outcomes static/js/scheduler_tab.js:71-100 untested browser: load History with mixed rows, assert the chips and the battery filter no
OUT-15 medium record_dispatch runs only when execute returned ok, so a refused force leaves no row outcomes scheduler.py:1582-1583 untested unit: execute returns ok False; assert no row no
OUT-19 medium Stop is shown when active_now: enabled, inside the window on the container clock and fired this occurrence; never for always outcomes scheduler.py:509-520, app.py:3033-3038, templates/tabs/scheduler.html:171-173 untested browser: a fired entry inside its window shows Stop; a gated one does not no
OUT-25 medium Stop with no active row logs stopped 'no active dispatch to stop' and does not block the rest of the window outcomes app.py:3165-3167 untested unit (API): gated entry, POST /stop, then conditions pass, assert it fires no
OUT-26 medium A scheduled or Run now force that returns ok writes an active dispatches row (signed watts or percent, window end) outcomes scheduler.py:1582-1597 untested unit: fire a force entry with a real store, assert one active row with schedule_id and window_end_ts no
OUT-43 medium Run now builds its snapshot from the gateway state and HA entities only, so other placeholders render '?' outcomes app.py:3436-3439 untested unit (API): notify with %tariff.buy_rate%; assert '?' no
PRI-01 medium priority is any integer (default 0) and conflict is free text (default override); anything other than defer or wait acts as override priorities app.py:660-665, scheduler.py:2129-2139, templates/tabs/scheduler.html:528-541 untested unit (API): POST conflict 'xyz' and priority -5, assert accepted; tick with a busy battery, assert it takes control no
TGT-06 medium History rows carry schedule id and name but no gateway targets db.py:1004-1023, scheduler.py:2458 untested unit: all-scope fire on two gateways gives two identical fired rows; decide to add the gateway no
TGT-15 medium Timeline segments ignore the gateway; markers come from the whole log; the tab sends no gateway targets app.py:3327-3358, scheduler.py:864-885, static/js/scheduler_tab.js:71-74 untested unit (API): timeline?gateway=gw2 still lists a schedule bound to gw1 no
TGT-19 medium Editor Test uses the top-bar gateway; Test against live reads get_state(), the default gateway targets static/js/scheduler_tab.js:606-613, static/js/scheduler_tab.js:921-931, app.py:3360-3369, app.py:3397-3402 untested unit (API): /test on a schedule bound to gw2 uses gw2's state (fails today) no
TGT-21 medium The gateway select appears only with more than one gateway; Default gateway stores gateway_id "" targets templates/tabs/scheduler.html:542-549, static/js/scheduler_tab.js:244-249 untested browser: with one gateway the select is hidden; with two it lists both plus Default gateway no
TRG-03 medium Window tests compare minute-of-day; seconds are ignored triggers scheduler.py:265-270, scheduler.py:384 untested unit: window_now at 18:00:59 and 18:01:00 for a 18:00 duration-1 entry no
TRG-20 medium interval_min 0 or less never opens and next_fire is None; not rejected at save triggers scheduler.py:333-335, scheduler.py:468-470, templates/tabs/scheduler.html:485-486 untested unit: window_now and next_fire_ts with interval_min 0 no
TRG-24 medium An invalid or empty cron expression is accepted at save and never opens triggers scheduler.py:350-366, scheduler.py:477-485, templates/tabs/scheduler.html:410 untested unit (API): POST cron 'nonsense' (decide whether to reject); unit: window_now outside no
TRG-31 medium Quick presets reset days, months, day_of_month and windows, then set only trigger fields triggers static/js/scheduler_tab.js:676-697 untested browser: apply 'Weekdays 18:00' over a monthly form; assert months cleared no
TRG-41 medium A window never seen by a tick is never claimed and so never reported missed triggers scheduler.py:2142-2156, db.py:2102-2131 untested unit: no tick during a window; sweep reports nothing no
TRG-53 medium Changing the trigger type seeds a 06:00-09:00 window, 30 min interval or '0 3 * * *' cron when empty triggers static/js/scheduler_tab.js:669-674 untested browser: switch types and read the form no
ACT-01 medium An action is sent once per occurrence; due() refuses the same occurrence key once it is marked fired actions scheduler.py:538-541, scheduler.py:2453-2458 tests/test_scheduler.py::test_fires_once_per_day — yes
ACT-22 medium Mode is a picker of tou/self/backup from the gateway's work modes; set_mode sends it over Direct Connect actions static/js/scheduler_tab.js:451-462, scheduler.py:1785-1787, client.py:371-380 tests/test_scheduler.py::test_operating_mode_is_a_picker_not_a_text_box — yes
ACT-31 medium A force with no host returns "force skipped" and sends nothing actions scheduler.py:1542-1543 tests/test_scheduler.py::test_force_without_dispatch_path_skips_safely — yes
ACT-32 medium reserve_soc is always skipped and a skipped verdict is recorded as a successful run actions scheduler.py:1847-1849, scheduler.py:2424-2429 tests/test_fire_entry_outcomes.py::test_a_skipped_action_is_not_a_failure — yes
ACT-34 medium On/Off is read strictly by on_value (true/on/yes/1, false/off/no/0, text or not); anything else raises and nothing is written; create/update store a real bool and refuse an unreadable value with 422 actions scheduler.py:1717-1748, scheduler.py:1789-1799, app.py:3081-3094, app.py:3122, app.py:3297-3301 tests/test_schedule_on_off_text.py::test_off_turns_a_smart_circuit_off — yes
CLK-01 medium The tick evaluates each gateway in that aGate's wall-clock offset (cached 10 min), falling back to the container clock when unreadable restarts-and-clock site_time.py:39-107, scheduler.py:1947-1948 tests/test_site_clock_scheduler.py::test_tick_derives_the_site_clock_and_fires_on_it — yes
CLK-05 medium An unreadable gateway gives offset None and the container-local clock, never UTC restarts-and-clock site_time.py:72-107 tests/test_site_clock_scheduler.py::test_unreadable_gateway_is_not_reported_as_utc — yes
CLK-06 medium An implausible aGate clock is not used as an offset; the base offset or the container clock is used restarts-and-clock site_time.py:27-69 tests/test_site_clock_scheduler.py::test_an_impossible_offset_is_refused_outright — yes
CND-01 medium a None live value makes every operator false, including != and the negated list/pattern operators conditions scheduler.py:159-163 tests/test_scheduler.py::test_missing_sensor_never_counts_as_true — yes
CND-05 medium < <= >= > coerce both sides to float (bools are not numbers) and are false if either is not numeric conditions scheduler.py:168-172, scheduler.py:95-105 tests/test_scheduler.py::test_numeric_comparison_handles_string_values — yes
CND-16 medium ALL needs every item, ANY at least one; an empty nested ALL is true, an empty nested ANY false; an empty top-level block is true whatever its match conditions scheduler.py:241-262 tests/test_scheduler.py::test_match_all_and_any — yes
CND-17 medium a node with conditions or match is a group and counts as one item of its parent conditions scheduler.py:207-216 tests/test_scheduler.py::test_exit_condition_nested_group_ends_window — yes
CND-22 medium tariff and service sensors come from the gateway's own meter, else the default meter (billing-window, demand, energy, bonus, export-charge and fixed use the same meter lookup); differs from the Modbus Bridge, which uses the gateway with id default conditions scheduler.py:1466-1478, scheduler.py:1954-1960, billing.py:349-361 tests/test_utilities_tariffs.py::test_tariff_snapshot_from_gateway_meter — yes
CND-33 medium HA states coerce to float, 1 (on/true/open/home), 0 (off/false/closed/away) or text; unavailable/unknown/none/empty are no reading and are left out of the snapshot, so conditions on them are false conditions scheduler.py:937-956, scheduler.py:978-986 tests/test_scheduler.py::test_unavailable_or_unknown_ha_entity_never_satisfies_a_condition — yes
CND-37 medium tariff_price_entity and tariff_feedin_entity map to tariff.spot_price and feed_in_price in c/kWh with $ units scaled x100, applied after NEM so they win conditions scheduler.py:1203-1248, scheduler.py:1307-1327, scheduler.py:1952-1953 tests/test_aemo_nem.py::test_ha_price_snapshot_units_and_mapping — yes
CON-01 medium The worker ticks every scheduler_tick_s (default 15, clamped 5-60) concepts scheduler_worker.py:53-67, scheduler_worker.py:148-176, scheduler_worker.py:202-214 tests/test_scheduler_worker.py::test_cadence_is_clamped_so_no_expressible_window_can_be_missed — yes
CON-04 medium An entry without a gateway action claims no resource and never contends concepts scheduler.py:2183-2212, scheduler.py:2084-2094 tests/test_scheduler.py::test_a_notify_rule_claims_nothing — yes
CON-08 medium A disabled gateway, one that never polled, or one whose snapshot is older than max(90 s, 3 poll intervals) is skipped with a reason concepts scheduler_worker.py:82-104, scheduler_worker.py:156-160 tests/test_scheduler_worker.py::test_a_stale_snapshot_is_skipped_with_a_reason — yes
CON-09 medium Each window opening is one occurrence; a run is claimed per occurrence and gateway, finished ok marks it fired, failed leaves it retryable inside the window concepts scheduler.py:369-392, scheduler.py:538-541, scheduler.py:2142-2156, db.py:1926-1933 tests/test_occurrences.py::test_a_failure_leaves_the_run_retryable_against_real_sql — yes
EXT-01 medium When a force entry's window closes the exit edge sends a Release to the gateway and logs exit 'window closed' (the test asserts the Release on an always entry's closing edge) exit-and-conflicts scheduler.py:1995-2038 tests/test_scheduler.py::test_trigger_always_fires_on_rising_edge unit: a daily force entry, tick inside then after fire_at+duration, assert Release and an exit row 'window closed' yes
EXT-02 medium After a run ends inside its window (here by an exit condition) the entry does not fire again in that occurrence, because the occurrence is marked fired exit-and-conflicts scheduler.py:538-541, scheduler.py:2453-2458 tests/test_scheduler.py::test_exit_condition_ends_window_early_and_releases — yes
EXT-03 medium Each occurrence has its own key (date, date#n for later windows, slot or cron timestamp), so a fired mark for one occurrence does not block the next exit-and-conflicts scheduler.py:369-392 tests/test_scheduler.py::test_trigger_window_multiple_windows — yes
EXT-07 medium A schedule whose exit conditions already hold in an open window is not started (reason 'exit condition already holds') exit-and-conflicts scheduler.py:2045-2048 tests/test_scheduler.py::test_exit_condition_blocks_firing_when_already_true — yes
EXT-08 medium Exit conditions becoming true run the exit edge: Release for a force, an exit row with 'exit condition met', and no re-fire in that occurrence exit-and-conflicts scheduler.py:2014-2038 tests/test_scheduler.py::test_exit_condition_ends_window_early_and_releases — yes
EXT-22 medium An exit tree with a nested group is evaluated like an entry tree and its true edge releases the force exit-and-conflicts scheduler.py:2007-2008, scheduler.py:207-262 tests/test_scheduler.py::test_exit_condition_nested_group_ends_window — yes
EXT-27 medium With restore_on_exit, the current mode.name is captured before the first attempt and stored once per occurrence; nothing is captured when it cannot be read (see also test_a_retry_cannot_overwrite_the_captured_prior_state) exit-and-conflicts scheduler.py:2157-2167, scheduler.py:2237-2254, db.py:1883-1889 tests/test_scheduler.py::test_capture_reads_the_mode_that_is_about_to_be_replaced — yes
EXT-28 medium A PUT without restore_on_exit keeps the stored value (merge, #157) exit-and-conflicts app.py:3111-3127 tests/test_schedule_update_merge.py::test_a_form_save_keeps_api_only_fields — yes
IDX-08 medium The toggle's partial PUT changes only enabled; every other stored field is kept index static/js/scheduler_tab.js:578-592, app.py:3105-3134 tests/test_schedule_update_merge.py::test_the_toggle_body_changes_only_enabled — yes
IDX-10 medium Eleven presets; the four force presets need Modbus, carry an SoC gate and a target SoC, and validate as imports index scheduler.py:554-623 tests/test_scheduler.py::test_dispatch_presets_present_and_valid — yes
OUT-04 medium A failed run is retried after 30 s doubling to a 600 s cap while its window is open outcomes scheduler.py:2353-2372, scheduler.py:2151-2153 tests/test_occurrences.py::test_backoff_grows_and_is_capped — yes
OUT-05 medium A successful attempt marks the occurrence fired, closes the run ok and logs fired outcomes scheduler.py:2453-2458 tests/test_fire_entry_outcomes.py::test_success_marks_fired_and_closes_the_run — yes
OUT-31 medium An UNKNOWN outcome settles the run as unknown, marks it fired, logs unknown and is not retried outcomes scheduler.py:2437-2452 tests/test_fire_entry_outcomes.py::test_an_unknown_write_is_never_retried_and_settles_the_run — yes
OUT-42 medium A failed attempt is not marked fired, the run is finished failed, and an error row names the attempt and the retry delay (see also test_a_failed_action_does_NOT_mark_fired) outcomes scheduler.py:2459-2466 tests/test_fire_entry_outcomes.py::test_a_failure_is_logged_with_the_retry_interval — yes
PRI-02 medium Among entries ready on the same tick and claiming the same resource, the highest priority fires and only the winner touches the battery priorities scheduler.py:2078-2118 tests/test_scheduler.py::test_priority_resolution_defers_lower_on_same_target — yes
PRI-05 medium force, set_mode and reserve_soc claim the same '/battery' resource, so a mode entry and a force entry contend priorities scheduler.py:2183-2212 tests/test_scheduler.py::test_force_and_mode_claim_the_same_battery — yes
PRI-06 medium A loser is logged deferred naming the winner, its occurrence is settled skipped and it is marked fired 'deferred', so it does not fire again in that occurrence priorities scheduler.py:2104-2118, scheduler.py:2296-2314 tests/test_scheduler.py::test_priority_resolution_defers_lower_on_same_target — yes
PRI-07 medium If busy is applied only to force winners, against active dispatches rows on the host whose schedule_id is another schedule or _widget priorities scheduler.py:2119-2140, db.py:2257-2266, app.py:3570-3574 tests/test_scheduler.py::test_conflict_defer_skips_when_battery_busy — yes
PRI-08 medium defer does not touch the battery, logs deferred and marks the entry fired 'deferred' for today's date priorities scheduler.py:2130-2134 tests/test_scheduler.py::test_conflict_defer_skips_when_battery_busy — yes
PRI-09 medium override sends its own force over another schedule's running dispatch priorities scheduler.py:2139-2140 tests/test_scheduler.py::test_conflict_override_takes_control — yes
PRI-13 medium The claim uses the tick's gateway for scope all and for unbound entries, so all-scope rules contend per gateway and an unbound entry contends with one bound to the default gateway priorities scheduler.py:2084-2092 tests/test_scheduler.py::test_an_all_scope_rule_claims_each_gateway_separately — yes
PRI-16 medium wait does not mark the entry fired, logs waiting and retries on the next tick priorities scheduler.py:2135-2138 tests/test_scheduler.py::test_conflict_wait_retries_not_fired — yes
RST-02 medium At start-up active dispatch rows are reconciled by dispatch_interrupt_policy (none, notify default, release, resume); notify never touches the battery restarts-and-clock app.py:1004-1023, battery_control.py:154-270 tests/test_dispatch_reconcile.py::test_notify_never_touches_battery — yes
RST-03 medium last_fired_day is stored, so after a restart an entry that already fired this occurrence does not fire again; one that has not fires on the first tick that passes restarts-and-clock scheduler.py:538-541, db.py:920-931 tests/test_scheduler.py::test_fires_once_per_day — yes
RST-06 medium resume re-forces for the remaining window when the battery is no longer forced; release only releases or re-arms restarts-and-clock battery_control.py:154-270 tests/test_dispatch_reconcile.py::test_resume_reforces_when_battery_dropped_inside_window — yes
SEN-03 medium snapshot_from_state maps soc, battery_w, grid_w, solar_w, load_w, generator_w, mode, run_status, programme_id, latency, vpp, off_grid, debug and kwh_sun to sensor ids conditions scheduler.py:888-925 tests/test_scheduler.py::test_snapshot_marks_derived_values — yes
SEN-04 medium grid.connected is int(grid_w != 0), None without a grid reading (differs from the Modbus Bridge, which uses the connection state) conditions scheduler.py:911-913 tests/test_scheduler.py::test_snapshot_marks_derived_values — yes
SEN-06 medium derived battery sensors use the capacity constant; max-rate ETAs use ratings.* from Model 702 else battery_max_power_kw; now ETAs need movement toward the target conditions scheduler.py:1091-1168, scheduler.py:1054-1070 tests/test_derived_sensors.py::test_eta_at_max_rate_prefers_modbus_ratings — yes
SEN-07 medium const.* come from the constants store with defaults 20/100/30 %, capacity 0 and max power 5 kW, and the default mode self; they are never missing conditions constants.py:19-29, constants.py:100-105, scheduler.py:1037-1051 tests/test_constants.py::test_constants_snapshot_keys — yes
SEN-08 medium window flags are 1/0 when the tariff defines that window and None otherwise; an unconfigured meter gives an empty snapshot conditions scheduler.py:1438-1463, scheduler.py:1481-1518 tests/test_billing_windows.py::test_billing_snapshot_windows_active — yes
SEN-10 medium tariff.spot_price is the NEM c/kWh price for nem_region, empty without a region conditions scheduler.py:1171-1182, aemo_nem.py:19, aemo_nem.py:33-47, scheduler.py:1285-1304 tests/test_aemo_nem.py::test_nem_snapshot — yes
SEN-11 medium service.* permissions default to 1 when the utility leaves them unset conditions scheduler.py:1492-1502 tests/test_utilities_tariffs.py::test_tariff_snapshot_from_gateway_meter — yes
TGT-01 medium A bound schedule is skipped on every other gateway's tick targets scheduler.py:1978-1988 tests/test_scheduler.py::test_bound_schedule_fires_only_on_its_gateway — yes
TGT-04 medium Each gateway gets its own occurrence row and its own resource claim for an all-scope rule targets scheduler.py:2084-2094, scheduler.py:2317-2329, db.py:1935-1962 tests/test_occurrences.py::test_the_same_rule_on_two_gateways_is_two_runs — yes
TGT-08 medium A disabled, never-polled or stale gateway is not evaluated at all, silently targets scheduler_worker.py:94-104, scheduler_worker.py:156-160 tests/test_scheduler_worker.py::test_stale_gateways_are_not_passed_to_tick_at_all — yes
TGT-23 medium A form save (which never sends gateway_scope) keeps gateway_scope all targets app.py:3111-3127 tests/test_schedule_update_merge.py::test_a_form_save_keeps_api_only_fields — yes
TRG-01 medium A fire-based entry is open from Fire at to Fire at + duration_min (each slot or cron match for interval and cron) triggers scheduler.py:297-321, scheduler.py:369-392 tests/test_scheduler.py::test_due_reports_why_it_did_not_fire — yes
TRG-05 medium An empty days list means every day; a weekday not in the list is blocked with the reason 'not one of its days of the week' triggers scheduler.py:443-445 tests/test_scheduler.py::test_recurrence_days_of_week — yes
TRG-06 medium Several windows: open inside any; window 0 uses the date as its key and window N uses date#N, so each fires once a day triggers scheduler.py:297-311, scheduler.py:385-391 tests/test_scheduler.py::test_trigger_window_multiple_windows — yes
TRG-11 medium A one-off is start_date == end_date; it is open only on that date (days, if stored, still restrict) triggers static/js/scheduler_tab.js:546, scheduler.py:430-445 tests/test_scheduler.py::test_recurrence_date_range_and_one_off — yes
TRG-12 medium After its date a one-off never opens again and next_fire is None triggers scheduler.py:458-506, static/js/scheduler_tab.js:715, static/js/scheduler_tab.js:732 tests/test_scheduler.py::test_recurrence_date_range_and_one_off — yes
TRG-14 medium Daily opens every day at fire_at and fires at most once per date key triggers scheduler.py:369-392, scheduler.py:538-541 tests/test_scheduler.py::test_fires_once_per_day — yes
TRG-16 medium A weekly trigger with no days has no restriction and fires every day triggers scheduler.py:443-445 tests/test_scheduler.py::test_recurrence_empty_fires_every_day — yes
TRG-17 medium Interval slots are anchor + k*interval_min from the most recent anchor; each slot opens for duration_min (1 when 0) triggers scheduler.py:332-347 tests/test_scheduler.py::test_trigger_interval — yes
TRG-21 medium Day of month is clamped to the month length; months restrict; empty months = all triggers scheduler.py:446-454 tests/test_scheduler.py::test_recurrence_months_and_day_of_month_with_clamp — yes
TRG-26 medium A cron entry is open from the previous match for duration_min (1 when 0); next fire is the next match triggers scheduler.py:350-366 tests/test_scheduler.py::test_trigger_cron — yes
TRG-27 medium An always entry's window is open while its entry conditions hold; it fires on the rising edge and not again while they keep holding triggers scheduler.py:1999-2002, scheduler.py:2049-2051 tests/test_scheduler.py::test_trigger_always_fires_on_rising_edge — yes
TRG-30 medium When an always entry's conditions stop, the closing edge sends a force Release and runs exit HA actions triggers scheduler.py:2014-2033 tests/test_scheduler.py::test_trigger_always_fires_on_rising_edge — yes
TRG-50 medium A calendar restriction that blocks today is reported as the reason in due() triggers scheduler.py:430-442, scheduler.py:535-537, templates/tabs/scheduler.html:509-522 tests/test_scheduler.py::test_due_reports_recurrence_reason — yes
TRG-55 medium A gateway whose snapshot is older than max(90 s, 3 poll intervals), or never polled, is skipped and not evaluated triggers scheduler_worker.py:82-104, scheduler_worker.py:156-160 tests/test_scheduler_worker.py::test_stale_gateways_are_not_passed_to_tick_at_all — yes
TRG-56 medium Older open runs of the same schedule and gateway are closed as skipped (coalesced) when a newer run is claimed triggers scheduler.py:2330-2346, db.py:1964-2000 tests/test_occurrences.py::test_an_older_open_run_is_collapsed_into_the_newer_one — yes
ACT-27 low An empty action claims no resource and only runs the HA actions actions scheduler.py:2183-2212, scheduler.py:2408-2417 tests/test_scheduler.py::test_a_notify_rule_claims_nothing — no
ACT-29 low An unknown action kind returns "unknown action ''", which the classifier reads as skipped actions scheduler.py:1853, scheduler.py:1642-1643 tests/test_scheduler.py::test_unavailable_action_reports_the_reason_when_run — no
ACT-33 low A smart-circuit result of NOT confirmed is a failure and the run stays open for retry actions scheduler.py:1789-1794, client.py:644-652, scheduler.py:2207-2212 tests/test_fire_entry_outcomes.py::test_an_unconfirmed_write_counts_as_failure — no
ACT-37 low smart_circuit_schedule is listed as unavailable with its reason, is skipped if run and is an import error actions scheduler.py:73-77, scheduler.py:1851-1852, scheduler.py:846-850, templates/tabs/scheduler.html:64-71 tests/test_scheduler.py::test_unavailable_action_reports_the_reason_when_run — no
CLK-03 low Setting the site timezone drops the cached clock offsets restarts-and-clock site_time.py:110-113 tests/test_setup_timezone_api.py::test_setting_the_zone_drops_the_cached_offsets — no
CND-30 low POST /api/schedules/evaluate evaluates an unsaved tree against live values and returns result plus a per-leaf trace (the editor maps rows by cid) conditions app.py:3397-3422, static/js/scheduler_tab.js:901-939 tests/test_constants.py::test_api_constants_get_put_and_sensor_group — no
CND-34 low ha_snapshot reads each enabled instance's full /api/states and keeps only exposed ids; an unreachable instance contributes nothing conditions scheduler.py:959-986, ha_instances.py:64-67 tests/test_scheduler.py::test_only_exposed_entities_are_read_not_the_whole_instance — no
CON-03 low The tick's snapshot is built from the poller's last_state for that gateway, not from a device read concepts scheduler_worker.py:107-116, scheduler.py:1947-1970, site_time.py:25, battery_control.py:449-475 tests/test_scheduler_worker.py::test_context_carries_the_snapshot_the_poller_wrote — no
EXT-10 low The exit row reads 'exit condition met' or 'window closed' plus the exit steps' results; it names no condition rows exit-and-conflicts scheduler.py:2034-2038 tests/test_scheduler.py::test_exit_condition_ends_window_early_and_releases — no
HAA-01 low HA actions are not validated at save; at run time a missing instance or domain/service is skipped with the reason actions scheduler.py:1801-1827, app.py:629-688 tests/test_scheduler.py::test_ha_service_requires_instance_and_fields — no
HAA-09 low %id% is replaced by the snapshot value, or ? when missing actions scheduler.py:191-204 tests/test_scheduler.py::test_unreadable_variable_renders_as_question_mark_not_a_failure — no
HAA-14 low Placeholders are substituted in a service call's entity_id and string data values actions scheduler.py:1835-1841 tests/test_scheduler.py::test_ha_service_action_calls_the_service — no
HAA-15 low Raw service data that is not JSON skips the call with the reason actions scheduler.py:1828-1834 tests/test_scheduler.py::test_ha_service_bad_json_is_reported — no
HAA-16 low A guided entity action calls domain.service with actions scheduler.py:1818-1845, static/js/scheduler_tab.js:383-399 tests/test_scheduler.py::test_ha_guided_entity_action_calls_domain_service — no
HAA-17 low run.* and schedule.* variables join the snapshot for fire and exit, in the same %name% syntax actions scheduler.py:2375-2394, scheduler.py:2405-2406, scheduler.py:2017-2018 tests/test_scheduler.py::test_run_variables_substitute_in_the_same_namespace_as_sensors — no
IDX-05 low Import is a dry run unless dry_run=false, creates valid entries disabled and maps a franklinwh-automations bundle field by field index app.py:3251-3310, scheduler.py:631-861 tests/test_scheduler.py::test_import_maps_modbus_automations_bundle — no
IDX-06 low Planned windows: enabled entries only, today's calendar only; interval, cron and always are not drawn index scheduler.py:864-885, app.py:3328-3358 tests/test_scheduler.py::test_disabled_schedule_absent_from_timeline — no
OUT-02 low The schedule log is newest first and filters by schedule_id and status (store level) outcomes app.py:3311-3325, db.py:1025-1043 tests/test_scheduler.py::test_fire_history_is_logged_and_filterable — no
OUT-13 low A widget force writes an active dispatches row with direction, signed power, mode, target SoC and window; the widget Release closes it outcomes db.py:2214-2266 tests/test_dispatch_reconcile.py::test_widget_force_is_persisted_and_release_closes_it — no
OUT-29 low Runs whose window ended more than 120 s ago while pending/running/failed are swept to missed and logged missed (store level; the worker's log row is untested) outcomes scheduler_worker.py:39, scheduler_worker.py:119-145, db.py:2102-2131 tests/test_occurrences.py::test_grace_lets_a_late_run_still_catch_up — no
OUT-30 low Older open runs of the same schedule and gateway are closed skipped as coalesced into the newer one outcomes scheduler.py:2330-2346, db.py:1964-2000 tests/test_occurrences.py::test_an_older_open_run_is_collapsed_into_the_newer_one — no
OUT-37 low Resume re-opens a paused run only while its window is open, else refuses (store level) outcomes app.py:3205-3229, db.py:2055-2078 tests/test_occurrences.py::test_resume_refuses_once_the_window_has_closed — no
OUT-38 low At worker start every paused run is resolved to stopped outcomes scheduler_worker.py:190-200, db.py:2080-2100 tests/test_occurrences.py::test_a_restart_while_paused_resolves_to_terminal — no
OUT-39 low Each run is claimed in occurrences before it is attempted; stats count runs, ok, missed and failed with first, last and last failure (store level) outcomes scheduler.py:2142-2156, scheduler.py:2317-2350, db.py:2162-2211, app.py:3231-3242 tests/test_occurrences.py::test_stats_answer_runs_first_last_and_last_failure unit (API): GET /api/schedules/runs and /stats return the rows, incl. skipped/stopped/paused counts no
OUT-40 low Log rows are classed event/error/exception from the status and capped per class, so the event cap cannot evict an error outcomes db.py:991-1022 tests/test_occurrences.py::test_routine_chatter_can_no_longer_evict_a_failure — no
OUT-41 low An entry with no action and no HA actions logs fired 'nothing to do' outcomes scheduler.py:2417, scheduler.py:2453-2458 tests/test_scheduler.py::test_a_fire_that_did_nothing_still_logs_why — no
PRI-14 low A successful force writes a dispatches row with schedule_id = the schedule id (scheduled or Run now) or _widget, and a Release from the widget closes it priorities scheduler.py:1582-1597, app.py:3562-3574, db.py:2214-2232 tests/test_dispatch_reconcile.py::test_widget_force_is_persisted_and_release_closes_it — no
PRI-17 low offgrid claims 'grid', each smart circuit its own circuit, a circuit with no number claims circuit:* which is merged with every specific circuit before a winner is picked; an unknown kind claims only itself (see also test_two_circuits_on_one_gateway_do_not_contend, test_offgrid_is_different_hardware_from_the_battery, test_an_unknown_action_contends_with_itself_not_the_battery) priorities scheduler.py:2183-2228, scheduler.py:2095-2101 tests/test_scheduler.py::test_an_unscoped_circuit_write_claims_every_circuit — no
PRI-18 low A notify or no-action entry claims no resource and always fires priorities scheduler.py:2189, scheduler.py:2200-2204, scheduler.py:2093-2094 tests/test_scheduler.py::test_a_notify_rule_claims_nothing — no
RST-05 low At worker start every paused run is resolved to stopped with a reason restarts-and-clock scheduler_worker.py:190-200, db.py:2080-2100 tests/test_occurrences.py::test_a_restart_while_paused_resolves_to_terminal — no
SEN-18 low GET /api/schedules lists every sensor with a group; the picker shows Gateway first and the rest alphabetically, with HA entities grouped by instance and relabelled with friendly names conditions app.py:3047-3073, static/js/scheduler_tab.js:998-1010 tests/test_billing_windows.py::test_billing_sensors_in_picker — no
TGT-14 low Import maps a default/unknown gateway to "" and creates the entry disabled targets scheduler.py:852-854, scheduler.py:728-729, app.py:3294-3309 tests/test_scheduler.py::test_import_maps_modbus_automations_bundle — no
TGT-18 low A mock gateway's tick passes an empty Modbus host, so no 702 ratings read targets scheduler_worker.py:107-116, scheduler.py:1967-1969 tests/test_scheduler_worker.py::test_mock_gateways_skip_the_modbus_read — no
TRG-32 low next_fire is the next window start within 366 days (skipping a fired or ended window today); next slot for interval; next match for cron; None for always or disabled triggers scheduler.py:458-506, app.py:3033-3038, static/js/scheduler_tab.js:510-529 tests/test_scheduler.py::test_next_fire_and_active_now — no
TRG-33 low active_now = enabled, inside the window and last_fired_day equals this occurrence; always is never active_now triggers scheduler.py:509-520 tests/test_scheduler.py::test_next_fire_and_active_now — no
TRG-40 low Claimed runs whose window ended more than 120 s ago without success are marked missed and logged triggers scheduler_worker.py:39, scheduler_worker.py:119-145, db.py:2102-2131 tests/test_occurrences.py::test_a_run_whose_window_closed_while_pending_is_missed — no