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 |
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 ' |
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 ' |
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 |