Scheduler test plan¶
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.
How to read it¶
| Column | Meaning |
|---|---|
| # | Behaviour id. The prefix names the page: CON concepts, TRG triggers, CND/SEN conditions, ACT/HAA actions, TGT targets, EXT exit, PRI priorities, OUT outcomes, RST/CLK restarts and clock, IDX overview. |
| 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_bridge/. |
| Current test | The unit test that covers it, or untested / unclear (the code is ambiguous or looks wrong; the test decides). |
| Proposed test | unit: pytest with the fake handlers in tests/unit/test_scheduler*.py. unit (API): an httpx test against the router. browser: Playwright against a throwaway bridge. VM/mock: a throwaway bridge with a mock gateway. |
| Battery write | Whether the behaviour decides if, when or how a command reaches a battery. |
The table is ordered by risk, then unclear before untested before tested.
Test safely
Battery-write tests run against a mock gateway on a throwaway bridge, never a real aGate and never the owner's live bridge. 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 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: gateway/scheduler.py:120-138 ;; tested: tests/unit/test_scheduler.py::test_inactive_outside_window ;; write: yes ;; b: what the code does ;; test: the test to add -->
Behaviours¶
238 behaviours: 130 tested, 93 untested, 15 unclear. Risk: 73 high, 120 medium, 45 low.
| # | Risk | Behaviour | Page | Code | Current test | Proposed test | Battery write |
|---|---|---|---|---|---|---|---|
| ACT-10 | high | Staged power is clamped to the handler's max_power_w (702 nameplate or 5000 W default) before the trusted cap, so a calibrated rating above the nameplate cannot be reached with an explicit power | actions | publish/command_handler.py:343-345, publish/command_handler.py:221-224 |
unclear | unit — handler with nameplate 5000, calibrated 8000; stage 7000; assert sent watts | yes |
| ACT-16 | high | Staging battery_command_power while a command is active re-sends the active command at the new power, so an override or re-send writes the old command again before the new one | actions | publish/command_handler.py:343-351, gateway/scheduler.py:319-329 |
unclear | unit — real handler with an active manual Force Charge; engine override with force_discharge; assert the sequence of send_command calls | yes |
| CLK-04 | high | Activation is computed on naive local time, so a window in the skipped DST hour may not run and one in the repeated hour may run twice | restarts-and-clock | gateway/scheduler.py:180-199, gateway/scheduler.py:129-138, gateway/scheduler_triggers.py:23-27 |
unclear | unit — with TZ Australia/Sydney, tick through the spring-forward and fall-back nights at 15 s steps for a daily 02:30 entry with 30 min duration; count dispatches | yes |
| CON-06 | high | Disabling the automations module only 403s the schedule routes; the engine still starts and runs enabled entries | concepts | main.py:963, main.py:1211-1218, api/modules_api.py:50-58 |
unclear | VM/mock — disable the module with an enabled entry whose window is open on a mock; check the Activity Log and the mock's command state | yes |
| EXT-05 | high | At an entry's window end, if another battery entry on the same target is active, _battery_winner returns that entry instead of None, so the run is not released; a gated successor leaves the old command running with no limit | exit-and-conflicts | gateway/scheduler.py:774-802, gateway/scheduler.py:835-836, gateway/scheduler.py:896-1018 |
unclear | unit — entries A (window 10-11) and B (always, gate fails) on one gateway; tick 10:30 (A dispatches), tick 11:01; assert whether Release is sent (expected yes, code says no) | yes |
| EXT-06 | high | The exit check uses the winner's exit tree against the owner's run, so when the winner differs from the owner another entry's exit conditions end the run | exit-and-conflicts | gateway/scheduler.py:896-918 |
unclear | unit — as EXT-05 with B having an exit condition that is true; assert the audit row's entry id | yes |
| EXT-19 | high | Once in hold mode, a window-exit or entry-removed reconcile matches neither branch, so disabling or deleting the entry never releases the 0 W hold | exit-and-conflicts | gateway/scheduler.py:851-867, gateway/scheduler.py:737-743 |
unclear | unit — release=hold entry; window exit (standby); remove the entry; tick; assert no Release (documents the defect) | yes |
| PRI-12 | high | Ownership is matched by action label only, so a manual Force Charge sent while an entry owns a Force Charge is treated as the entry's and released at window end | priorities | gateway/scheduler.py:960-962, gateway/scheduler.py:984-989 |
unclear | unit — entry owns Force Charge; a manual Force Charge at another power is sent; window ends; assert Release | yes |
| PRI-13 | high | Arbitration is per (target_type, target_id); entries reaching one gateway through different targets are not ranked by priority and see each other as manual control | priorities | gateway/scheduler.py:686-692, gateway/scheduler.py:713-727, gateway/scheduler.py:960-980 |
unclear | unit — gateway entry (priority 0, override) and site entry (priority 100, defer) both active on one gateway; tick several times; record who runs and whether the command alternates | yes |
| PRI-15 | high | Supervision runs before the foreign-control check, so a manual command replacing an unverified scheduled run can be overwritten by the not-effective re-send | priorities | gateway/scheduler.py:920-929, gateway/scheduler.py:1316-1364, gateway/scheduler.py:959-980 |
unclear | unit — VHandler: entry dispatches Force Charge; before verification switch the handler to a manual Force Discharge with battery_power_w discharging; tick past 90 s; assert whether the entry re-sends | yes |
| TGT-11 | high | target_id null and 'default' form two arbitration groups for the same physical gateway | targets | gateway/scheduler.py:686-692, main.py:570 |
unclear | unit — two entries, target_id None and 'default', priorities 10 and 0, both active; assert which one runs and whether the other logs deferred | yes |
| TGT-12 | high | The service select sets target_id only on change, and save sends target_id || 'default', so an untouched Service target is saved with a gateway id or 'default' | targets | static/js/schedule_tab.js:1416, templates/tabs/schedule.html:1247-1254 |
unclear | browser — new entry, switch Target to Service, save without touching the list; GET target_id | yes |
| TGT-16 | high | After a site/service member stops polling and comes back, the entry's earlier command is no longer owned and is treated as foreign (deferred), so it is not released at window end | targets | main.py:574-578, gateway/scheduler.py:745-753, gateway/scheduler.py:960-980 |
unclear | VM/mock — site entry running on a mock; stop the mock's polling for > 1 tick and restore; check the Activity Log for deferred and whether the window end releases | yes |
| TRG-29 | high | The done-for-this-window mark is one entry id per target and gateway; a dispatch by another entry on the same target clears it, letting an entry that already finished its window fire again in the same window | triggers | gateway/scheduler.py:428, gateway/scheduler.py:956-957, gateway/scheduler.py:1039 |
unclear | unit — entries A (window) and B (higher priority, short trigger) on one gateway; A exits by condition, B fires and ends; assert whether A re-fires in its still-open window | yes |
| ACT-01 | high | Release, reserve and mode are one-shot: sent once per activation, not re-sent while the window stays open, nothing undone at window end | actions | gateway/scheduler.py:57, gateway/scheduler.py:851, gateway/scheduler.py:882-883, gateway/scheduler.py:984-989 |
untested | unit — mode entry over a 1 h window: one operating_mode call, none at window exit | yes |
| ACT-06 | high | PATCH validates params only when action is in the update, so params.power_pct can be added to an existing entry | actions | api/schedules_api.py:619-621 |
untested | unit (API) — PATCH {'params': {'power_pct': 50}} returns 200 (defect) | yes |
| ACT-08 | high | Saving a legacy power_pct entry from the form drops power_pct; empty Power then means full trusted rating | actions | static/js/schedule_tab.js:1295-1307, templates/tabs/schedule.html:712-713 |
untested | browser — open a legacy entry, save with Power empty, GET params | yes |
| ACT-15 | high | The aGate does not release when WSetRvrtRem reaches 0 (PICS Issue 4); only the bridge releases | actions | publish/command_handler.py:504-512 |
untested | VM/mock is not enough — hardware observation only; record as a known device behaviour | yes |
| ACT-18 | high | params.target_soc releases a charge at SoC >= target and a discharge at SoC <= target, checked every 5 s; a target already reached at start is cleared | actions | publish/command_handler.py:373-388, publish/command_handler.py:474-489, publish/command_handler.py:980-1005 |
untested | unit — handler with points soc 79 then 80, target 80 charge; assert release reason target_soc_reached | yes |
| ACT-19 | high | Saving from the form replaces params, dropping API-only duration_s and target_soc | actions | static/js/schedule_tab.js:1295-1307, static/js/schedule_tab.js:1414 |
untested | browser — create via API with params.target_soc, save in the form, GET params | yes |
| ACT-21 | high | A release entry is not subject to the conflict policy and releases a manual command | actions | gateway/scheduler.py:332-333, gateway/scheduler.py:963-964 |
untested | unit — FakeHandler with an active manual command; release entry with conflict defer; assert Release sent | yes |
| ACT-24 | high | One-shot results are not checked: a failed mode or reserve write is audited ok — dispatched and is not retried | actions | gateway/scheduler.py:1043-1046, gateway/scheduler.py:1085-1088 |
untested | unit — handler whose mode write fails; assert the audit row is ok (documents the gap) | yes |
| ACT-26 | high | Mode and reserve entries are not subject to the conflict policy and write during manual control | actions | gateway/scheduler.py:963-964 |
untested | unit — manual Force Charge active; mode entry with conflict defer; assert operating_mode sent | yes |
| CLK-01 | high | The engine, the API previews and the time sensors use naive datetime.now(): the bridge process's local clock, the same for every gateway | restarts-and-clock | gateway/scheduler.py:396, gateway/scheduler_triggers.py:16-21, api/schedules_api.py:290, gateway/scheduler_sensors.py:909-911 |
untested | unit — run tick with TZ set to two zones (monkeypatch time.tzset) and compare activation | yes |
| CND-04 | high | A bool sensor equals the number 1 or 0 (Python True == 1); the text 'true' never matches | conditions | gateway/scheduler_conditions.py:105-113, static/js/schedule_tab.js:1309-1316 |
untested | unit — grid.connected True == 1 passes, == 0 fails, == 'true' fails | yes |
| CND-19 | high | The form prunes empty groups and saves an empty block as null (no gate / no exit) | conditions | static/js/schedule_tab.js:1318-1347 |
untested | browser — add an empty group and save; GET shows it gone; clear all rows and save; GET shows null | yes |
| CND-20 | high | An API exit tree {match: ALL, conditions: []} is true and releases the run on the next tick; an empty ANY tree never exits | conditions | gateway/scheduler.py:902-905, gateway/scheduler_conditions.py:330-332 |
untested | unit — entry with exit_conditions {'match':'ALL','conditions':[]}; tick twice; assert Release on the second tick | yes |
| CND-21 | high | For a site or service target each member gateway's conditions are evaluated on that gateway's own snapshot | conditions | gateway/scheduler.py:724-727, gateway/scheduler.py:1464-1480, main.py:651-681 |
untested | unit — site target, two FakeHandlers, points_fn per gateway with soc 80 and 10; gate soc > 50; assert only the first dispatches | yes |
| CND-22 | high | demand., bonus., export-charge and billing-period energy sensors measure the default gateway and are merged into every gateway's snapshot | conditions | main.py:643-646, main.py:672-680 |
untested | unit — _schedule_points for a second gateway returns the default gateway's demand values | yes |
| CND-25 | high | The hold-for timer resets at window exit | conditions | gateway/scheduler.py:847-848 |
untested | unit — hold 300 s, window ends after 200 s eligible, re-opens; assert no fire until 300 s more | yes |
| CND-27 | high | A retry after a failed send skips the hold-for wait | conditions | gateway/scheduler.py:1023 |
untested | unit — hold 120 s, first send fails; assert the retry at +15 s sends without a new waiting row | yes |
| CON-01 | high | The engine evaluates every enabled entry once per 15-second tick; a failing tick is logged and the loop continues | concepts | gateway/scheduler.py:47, gateway/scheduler.py:673-682 |
untested | unit — run _loop with a fake sleep, make tick raise once, assert the loop continues and ticks again | yes |
| CON-05 | high | Every create, update, delete and import reloads the engine and runs an immediate tick, which can dispatch at once | concepts | api/schedules_api.py:194-200, gateway/scheduler.py:463-466 |
untested | unit (API) — create an entry whose window is open against a fake engine and assert reload() was awaited | yes |
| CON-07 | high | A window entry with no conditions is legacy and always does a plain release at window end, even when its release_policy is restore_prior_mode | concepts | gateway/scheduler.py:172-177, gateway/scheduler.py:867-881 |
untested | unit — window entry with release_policy=restore_prior_mode and no conditions; assert no mode note and only Release at window exit (the existing test leaves the policy at its default) | yes |
| EXT-09 | high | Exit conditions are ignored for one-shot and none entries and for commands sent by Run now or a late fire | exit-and-conflicts | gateway/scheduler.py:896-900, gateway/scheduler.py:549-608 |
untested | unit — Run now a force entry with a true exit condition; tick; assert no release | yes |
| EXT-20 | high | Shutdown stops the engine before the gateways, so active commands are released by the handlers with reason shutdown, with no Activity Log row and no exit HA actions | exit-and-conflicts | main.py:1083-1085, gateway/instance.py:450-455, publish/command_handler.py:1057-1067 |
untested | VM/mock — stop the bridge while a mock run is active; check control log (shutdown release) and Activity Log (no row) | yes |
| IDX-04 | high | Templates open disabled and the enable prompt after save appears only when the form opened disabled | index | static/js/schedule_tab.js:1039-1126, static/js/schedule_tab.js:1478-1493 |
untested | browser — open a template, save, answer the prompt; check enabled | yes |
| OUT-06 | high | A pending retry is dropped when a different entry wins the gateway | outcomes | gateway/scheduler.py:991-995 |
untested | unit — entry A fails, entry B (higher priority) becomes active; assert A's retry is not attempted | yes |
| OUT-21 | high | Run now stages the entry's duration_s as the time limit; with none it stages 0 and the command is open-ended | outcomes | gateway/scheduler.py:569-571, gateway/scheduler.py:319-321 |
untested | unit — execute() a window entry with no duration; assert battery_command_duration '0' | yes |
| OUT-22 | high | Run now does not check the conflict policy and supersedes a manual command | outcomes | gateway/scheduler.py:585-600 |
untested | unit — manual Force Charge active; execute() a force_discharge entry with conflict defer; assert it is sent | yes |
| OUT-23 | high | execute() ignores the trigger window and the done-for-window mark | outcomes | gateway/scheduler.py:549-608 |
untested | unit — execute() an entry whose window is closed and one that is expired; both dispatch | yes |
| OUT-25 | high | Stop with nothing owned returns not_active but still blocks the rest of the current window on every target gateway | outcomes | gateway/scheduler.py:650-659 |
untested | unit — stop_entry() on a gated entry, then make the gate pass; assert no dispatch in that window | yes |
| PRI-03 | high | A running entry keeps the gateway while active even when a higher-priority entry becomes active; there is no preemption by priority | priorities | gateway/scheduler.py:779-784 |
untested | unit — low-priority entry running; a priority-100 entry's window opens; assert the low one keeps the gateway and the high one logs skipped | yes |
| PRI-04 | high | At equal priority the older entry (created_at ascending) ranks first | priorities | store/db.py:1772-1788 |
untested | unit — two equal-priority entries created in order; assert get_schedules order and the engine winner | yes |
| PRI-05 | high | Release, reserve and mode entries share the battery lane with force entries, so a one-shot entry can win the gateway and keep it for its whole window while a force entry is skipped | priorities | gateway/scheduler.py:722-723 |
untested | unit — mode entry (priority 10, 1 h window) and force entry (priority 0) overlapping; assert the force entry is skipped for the hour | yes |
| PRI-10 | high | After a deferred manual command ends, the entry dispatches if its window is still open and it has not completed the window | priorities | gateway/scheduler.py:937-953, gateway/scheduler.py:970-980 |
untested | unit — defer, then manual released mid-window; assert the entry dispatches on the next tick | yes |
| PRI-11 | high | A setpoint written by another tool leaves handler.state.active false, so entries dispatch over it; the handler logs external_dispatch_started and dispatch.source reads manual | priorities | publish/command_handler.py:596-646, gateway/scheduler.py:960-963, gateway/scheduler.py:1524-1535 |
untested | unit — handler inactive, points wset_enabled 1; entry with defer dispatches; dispatch_points source manual before the dispatch | yes |
| RST-02 | high | On gateway start-up a persisted active command is released for safety (reset_control_state, WSetRvrtTms and WSetEnaRvrt cleared) and the saved state is set inactive | restarts-and-clock | gateway/instance.py:264-270, gateway/instance.py:391-420 |
untested | unit — GatewayInstance with a saved active control state and a mock controller; start; assert reset_control_state was called and the state saved inactive | yes |
| RST-04 | high | Start-up order: gateways start (and release stale commands), then the engine starts, then startup catch-up runs | restarts-and-clock | main.py:946-985 |
untested | VM/mock — read the log order at start-up | yes |
| SEN-14 | high | gateway.device_type and gateway.battery_capable come from config (battery_capable false for mac1) merged into the engine's points | conditions | gateway/scheduler_sensors.py:510-540, main.py:656-661 |
untested | unit — snapshot({'device_type':'mac1'})['gateway.battery_capable'] is False | yes |
| TGT-01 | high | A gateway target resolves to that gateway's handler whenever the gateway is registered (started), without checking is_running; site and service require is_running | targets | main.py:569-573, gateway/registry.py:48-58 |
untested | unit — build the resolver over a fake registry; assert pairs for gateway, site and service targets | yes |
| TGT-02 | high | A site target resolves to every running gateway (registry.list_active), mock gateways included; target_id is ignored | targets | main.py:574-578 |
untested | unit — fake registry with a running real and a running mock and a stopped gateway; assert two pairs | yes |
| TGT-03 | high | A service target resolves to running gateways whose config.service_id equals target_id; a service target without an id resolves to nothing | targets | main.py:579-586 |
untested | unit — as TGT-02 with service ids | yes |
| TGT-07 | high | A member gateway that leaves a fan-out while owned is not released: the handler is re-resolved through the target, is not found, and the records are dropped (the command keeps running) | targets | gateway/scheduler.py:735-753 |
untested | unit — site target over gwA and gwB, both owned; remove gwA from the resolver; tick; assert gwA got no Release (documents the defect). Deleted-entry release is covered by test_deleted_entry_releases_owned_target | yes |
| TGT-08 | high | A target that resolves to no gateway is skipped silently (no dispatch, no audit row); a registered but stopped gateway on a gateway target is still sent to | targets | main.py:569-586, gateway/scheduler.py:724 |
untested | unit — resolver returns []; tick; assert no audit rows. VM/mock — stop the mock gateway; a gateway-target entry logs failed and retries | yes |
| TGT-09 | high | Run now on a fan-out target dispatches to every member gateway that passes its own gate | targets | gateway/scheduler.py:578-605 |
untested | unit — execute() with a two-gateway resolver; assert both handlers receive the command | yes |
| TGT-10 | high | A gateway target with target_id null resolves to the literal gateway id default | targets | main.py:570, api/schedules_api.py:228, api/schedules_api.py:115 |
untested | unit — resolver('gateway', None) returns the default gateway's handler; returns [] when there is no default | yes |
| TRG-02 | high | A fire-based entry with duration_s empty or 0 is active for 60 s, so a force command is released about a minute after it starts | triggers | gateway/scheduler.py:197-199 |
untested | unit — daily trigger, duration_s None; tick at fire+30 s (active, dispatch) and fire+61 s (Release sent) | yes |
| TRG-06 | high | An entry with several windows is active inside any one of them | triggers | gateway/scheduler.py:138 |
untested | unit — two windows 06:00-07:00 and 16:00-17:00; assert active at 06:30 and 16:30, inactive at 12:00 | yes |
| TRG-08 | high | For an overnight window the weekday test uses the current day, so the after-midnight part belongs to the next day's tick | triggers | gateway/scheduler.py:135-138 |
untested | unit — days=[0], 22:00-06:00; assert active Mon 02:00 and Mon 23:00, inactive Tue 02:00 | yes |
| TRG-10 | high | Times are parsed modulo 24 h, so 24:00 means 00:00 and 18:00-24:00 runs to midnight; unparsable times become 00:00 | triggers | gateway/scheduler.py:80-86, api/schedules_api.py:55-57 |
untested | unit — 18:00-24:00 active at 23:59, inactive at 00:01; _to_minutes('25:10') == 70 | yes |
| TRG-13 | high | An overnight window on a one-off date is active only on that date: the after-midnight part on the next date never runs | triggers | gateway/scheduler.py:131-133 |
untested | unit — date=D, 22:00-02:00; assert inactive at D+1 01:00 and active at D 01:00 | yes |
| TRG-15 | high | duration_s has no upper bound; a duration at or above the trigger period keeps the entry active continuously, so it never re-arms after a run ends | triggers | gateway/scheduler.py:194-199, api/schedules_api.py:125 |
untested | unit — interval 30 min with duration 30 min; end a run by exit condition; assert no re-fire at the next fire time | 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 | gateway/scheduler_triggers.py:129-136, gateway/scheduler_triggers.py:316-321 |
untested | unit — every 7 h; assert prev_fire_at at 23:00 is 21:00 and at 00:30 next day is 00:00 | yes |
| TRG-19 | high | An interval of 24 h or more fires once a day at the anchor | triggers | gateway/scheduler_triggers.py:316-321 |
untested | unit — every 2 days; assert fires on consecutive days at the anchor | yes |
| TRG-23 | high | A monthly trigger with no time_of_day fires at 00:00 | triggers | gateway/scheduler_triggers.py:154 |
untested | unit — monthly spec without time_of_day; assert next fire at 00:00 | yes |
| TRG-25 | high | Cron day-of-week follows croniter (0 or 7 = Sunday), unlike the weekly trigger and time.dow (0 = Monday) | triggers | gateway/scheduler_triggers.py:195-229 |
untested | unit — '0 17 * * 1' fires on a Monday and not on a Sunday | yes |
| TRG-28 | high | After a run of an always entry ends (exit, Stop, time limit), the entry never re-fires until restart because its window never closes; an always HA-only entry fires once per bridge run | triggers | gateway/scheduler.py:845, gateway/scheduler.py:943, gateway/scheduler.py:956-957, gateway/scheduler.py:984-989 |
untested | unit — always entry with exit condition; tick to dispatch, meet exit, clear exit; assert no second dispatch over many ticks | yes |
| TRG-34 | high | Saving a window entry from the form does not send duration_s, so an old duration stays and is used as Run now's time limit | triggers | static/js/schedule_tab.js:1453-1467, gateway/scheduler.py:570-571 |
untested | browser + unit — change a daily entry with 30 min to a window, save, GET it: duration_s still 1800 | yes |
| TRG-35 | high | Saving an API oneoff entry from the form sends trigger_spec {} and loses fire_at | triggers | static/js/schedule_tab.js:1132, static/js/schedule_tab.js:1349-1366 |
untested | browser — create a oneoff via API, open and save in the form, GET it: trigger_spec is {} | yes |
| TRG-44 | high | A late fire is not owned by the engine; with no duration it is staged with duration 0 and runs until released | triggers | gateway/scheduler.py:529-536, gateway/scheduler.py:319-321 |
untested | unit — late_fire_always on an entry with duration_s None; assert battery_command_duration staged as 0 and no Release on later ticks | yes |
| TRG-45 | high | A late fire does not check the conflict policy and supersedes a manual command | triggers | gateway/scheduler.py:533-536 |
untested | unit — FakeHandler with an active manual command; late_fire_always with conflict defer still sends | yes |
| TRG-48 | high | Window entries are caught up with their stored missed_policy although the form hides the selector for them | triggers | gateway/scheduler.py:506, templates/tabs/schedule.html:1281-1288, static/js/schedule_tab.js:1420 |
untested | unit — window entry with missed_policy late_fire_always; catch-up sends the action | yes |
| SEN-16 | medium | _dispatch_points takes the first owned record for the gateway, including an HA-only lane record, so an active HA-only entry reads as dispatch.active True / source schedule | conditions | gateway/scheduler.py:1503-1522, gateway/scheduler.py:731-733, gateway/scheduler.py:1057-1084 |
unclear | unit — HA-only always entry fires; assert dispatch_points(gw)['dispatch_source'] (expected none) | no |
| ACT-02 | medium | The API rejects an unknown action, mode without params.mode and a reserve without params.pct | actions | api/schedules_api.py:182-189 |
untested | unit (API) — POST each and assert 400 | no |
| ACT-05 | medium | POST with params.power_pct is rejected with 400 | actions | api/schedules_api.py:190-191 |
untested | unit (API) — POST force_charge with power_pct 50; assert 400 and the message | no |
| ACT-09 | medium | Duplicate of a legacy power_pct entry POSTs params.power_pct and fails with 400 | actions | static/js/schedule_tab.js:1552-1584, api/schedules_api.py:190-191 |
untested | browser — duplicate a legacy entry; expect 'Copy failed' | no |
| ACT-23 | medium | The API checks only that params.mode is present; an unknown mode name is saved and fails at run time with Unknown mode | actions | api/schedules_api.py:186-187, publish/command_handler.py:840-846 |
untested | unit (API) — POST mode 'Backup' returns 201 (documents the gap) | no |
| ACT-28 | medium | Run now on a none entry returns unknown_action and runs no HA action | actions | gateway/scheduler.py:596-598 |
untested | unit — execute() on a none entry returns status unknown_action | no |
| CLK-02 | medium | The Schedule page renders times and the one-off default date on the bridge's clock, with the zone label in the timeline and Activity Log headers | restarts-and-clock | templates/tabs/schedule.html:53, templates/tabs/schedule.html:503, static/js/schedule_tab.js:867-884, static/js/schedule_tab.js:72-74 |
untested | browser — browser in a different zone; Next fire and the default date follow the bridge | no |
| CND-13 | medium | The form sends number-looking values as numbers and the RHS of list and pattern operators as raw text | conditions | static/js/schedule_tab.js:1309-1316, static/js/schedule_tab.js:1333-1341 |
untested | browser — save '50' and 'in 10,20'; GET the entry and check types | no |
| CND-15 | medium | The Details panel (treeText) renders a Lookup row as 'sensor op undefined' because it ignores value_kind/value_sensor | conditions | static/js/schedule_tab.js:753-762 |
untested | browser — open Details of an entry with a Lookup row | no |
| CND-31 | medium | The evaluate endpoint builds points without dispatch_* and device_type, so dispatch.* and gateway.device_type/battery_capable differ from the engine's snapshot | conditions | api/scheduler_api.py:29-62, main.py:651-681 |
untested | unit (API) — evaluate gateway.battery_capable == 0 for a mac1 gateway returns false while the engine snapshot returns true | no |
| CND-32 | medium | POST /api/scheduler/evaluate is a write route and needs the admin role | conditions | main.py:1215-1218, api/auth.py:199-216 |
untested | unit (API) — a user-role session gets 403 on evaluate | no |
| EXT-10 | medium | The exit_condition_met detail is built from failing leaves, so it names the rows that did not match | exit-and-conflicts | gateway/scheduler.py:909, gateway/scheduler.py:273-276 |
untested | unit — ANY exit with one true and one false row; assert the detail names the false one (documents the confusing text) | no |
| EXT-15 | medium | release_policy is not validated; an unknown value behaves as a plain release | exit-and-conflicts | api/schedules_api.py:126, gateway/scheduler.py:1428-1447 |
untested | unit (API) — POST release_policy 'bogus' returns 201; engine releases plainly | no |
| EXT-16 | medium | Migration 25 defaults release_policy to restore_prior_mode for existing rows, which legacy entries then ignore | exit-and-conflicts | store/db.py:552, gateway/scheduler.py:172-177 |
untested | unit — migrate a v24 database; a legacy row reads restore_prior_mode and releases plainly | no |
| HAA-01 | medium | The API rejects an entity action without entity_id and a notify action without message; title and message are limited to 200 and 1000 characters | actions | api/schedules_api.py:72-105 |
untested | unit (API) — POST each invalid shape and assert 422 | no |
| HAA-02 | medium | The form silently drops incomplete HA action rows on save | actions | static/js/schedule_tab.js:1423-1445 |
untested | browser — add an entity row without an entity; save; GET ha_actions is empty | no |
| HAA-07 | medium | When the battery send fails, fire-time HA actions wait for the successful retry | actions | gateway/scheduler.py:1044-1046 |
untested | unit — VHandler failing once; assert the HA action runs only after the retry succeeds | no |
| HAA-10 | medium | Three-part ids and ha: ids are not substituted by the placeholder regex | actions | gateway/scheduler.py:1120 |
untested | unit — _render('%energy.grid_import.today_kwh%') is unchanged | no |
| IDX-01 | medium | The entry list shows enabled entries only by default | index | static/js/schedule_tab.js:236-241, templates/tabs/schedule.html:338 |
untested | browser — a disabled entry is hidden until the selector is All | no |
| IDX-02 | medium | Duplicate POSTs a copy named '(copy)' with enabled false | index | static/js/schedule_tab.js:1552-1584 |
untested | browser — duplicate; the copy is disabled | no |
| OUT-03 | medium | The fired filter matches no row because dispatches are audited ok; the log shows 40 rows | outcomes | static/js/schedule_tab.js:290, static/js/schedule_tab.js:322-324, gateway/scheduler.py:1088 |
untested | browser — click fired with dispatches present; the list is empty | no |
| OUT-12 | medium | Supervision runs only for runs the engine owns: not for Run now, late fires, manual commands or a hold | outcomes | gateway/scheduler.py:920-929 |
untested | unit — execute() a force entry; advance 90 s with a stationary battery; assert no not_effective row | no |
| OUT-14 | medium | The engine moves the row to verified, not_effective or reasserted; the engine tests run with db None, so these updates are not exercised | outcomes | gateway/scheduler.py:1306-1308, gateway/scheduler.py:1339-1340, gateway/scheduler.py:1348-1350, gateway/scheduler.py:1375-1377 |
untested | unit — engine with a real db and VHandler; assert the row states after verify and not_effective | no |
| OUT-15 | medium | A refused or unknown battery command creates no dispatch_outcomes row; only last_dispatch is set not-ok | outcomes | publish/command_handler.py:1014-1022, publish/command_handler.py:431-440 |
untested | unit — refused Force Discharge; assert dispatch_outcomes.recent() is empty and last_dispatch.ok is False | no |
| OUT-19 | medium | Stop is shown when active_now (window open, enabled) and Run now otherwise, regardless of whether a command is running | outcomes | templates/tabs/schedule.html:407-414, api/schedules_api.py:278 |
untested | browser — a gated entry in an open window shows Stop | no |
| PRI-01 | medium | priority is 0-1000 (default 0) and conflict is defer, override or wait (default defer); other values are rejected | priorities | api/schedules_api.py:118-119 |
untested | unit (API) — priority 1001 and conflict 'x' return 422 | no |
| TGT-13 | medium | The form warns when the selected gateway is a mock | targets | templates/tabs/schedule.html:1244-1245 |
untested | browser — pick a mock gateway; the amber warning shows | no |
| TGT-15 | medium | Timeline: site segments paint on every gateway, gateway segments on their own, service segments only in the All view | targets | static/js/schedule_tab.js:886-896 |
untested | browser — one entry of each target type; switch the timeline gateway selector | no |
| TRG-30 | medium | The form says an always entry releases when its conditions stop; the engine does not re-check the gate while running | triggers | templates/tabs/schedule.html:689-692 |
untested | none — doc/UI mismatch, fix the form text | no |
| TRG-31 | medium | Quick presets only fill the trigger fields of the form | triggers | static/js/schedule_tab.js:51-66, static/js/schedule_tab.js:1203-1212 |
untested | browser — pick each preset and compare the fields | no |
| TRG-33 | medium | active_now in the list means enabled and inside the trigger window, not that a command is running | triggers | api/schedules_api.py:262-280 |
untested | unit (API) — gated entry in an open window lists active_now true | no |
| TRG-41 | medium | Only one missed fire per entry per outage is recorded: the latest fire for triggers, the first window start after the outage began for windows | triggers | gateway/scheduler.py:220-227 |
untested | unit — interval 15 min, 1 h outage; assert exactly one missed row | no |
| TRG-42 | medium | missed_policy skip behaves exactly like late_fire_remaining: both only log missed | triggers | gateway/scheduler.py:506-515 |
untested | unit — catch-up with policy skip; assert one missed row and no command | no |
| TRG-46 | medium | A late fire runs no HA actions; a none entry with late_fire_always sends nothing and writes no audit row, only the outage link | triggers | gateway/scheduler.py:510-541 |
untested | unit — late_fire_always HA-only entry with passing gate; assert no audit rows and result missed == [id] | no |
| TRG-47 | medium | Catch-up audit rows pass a string target, so the Activity Log shows type:id:gateway instead of the gateway name | triggers | gateway/scheduler.py:508, gateway/scheduler.py:1552-1564 |
untested | unit — catch-up with gw_label_fn set; assert the target label | no |
| ACT-03 | medium | Force power is capped at the trusted rating for that direction; the library sizes WSetPct against the trusted rating | actions | publish/command_handler.py:441-454 |
tests/unit/test_battery_rating.py::test_trusted_rating_is_what_the_library_sizes_against |
— | yes |
| ACT-04 | medium | A force entry always stages its own duration, target SoC and power (0 when empty), so empty Power means the full trusted rating and no manual value leaks in | actions | gateway/scheduler.py:315-329 |
tests/unit/test_battery_rating.py::test_scheduled_empty_power_is_full_trusted_rating_not_a_manual_leftover |
— | yes |
| ACT-07 | medium | A legacy power_pct entry is sized as that share of the trusted rating; only schedule: requesters may stage a percentage | actions | gateway/scheduler.py:324-325, publish/command_handler.py:352-366, publish/command_handler.py:448-450 |
tests/unit/test_battery_rating.py::test_legacy_schedule_percent_is_sized_against_the_trusted_rating |
— | yes |
| ACT-11 | medium | A direction is trusted when calibrated or when the 702 rating is confirmed by a peak of 80-115 %; mock is always trusted; a contradiction revokes trust | actions | gateway/battery_rating.py:102-128 |
tests/unit/test_battery_rating.py::test_m702_alone_is_not_trusted |
— | yes |
| ACT-12 | medium | An untrusted direction refuses Force Charge/Discharge: nothing is written, command_refused is logged and last_dispatch is set not-ok | actions | publish/command_handler.py:431-440, publish/command_handler.py:1014-1022 |
tests/unit/test_battery_rating.py::test_refused_command_replaces_an_earlier_ok_result |
— | yes |
| ACT-13 | medium | A not-ok last_dispatch after a sustained send is audited failed and retried 15 s, 30 s, then every 60 s; tested with a fake failing handler, not with a real refusal end to end | actions | gateway/scheduler.py:1043-1046, gateway/scheduler.py:1234-1253 |
tests/unit/test_dispatch_verification.py::test_t1_failed_send_is_failed_not_ok_and_retried_with_backoff |
unit — real CommandHandler with an untrusted rating driven by the engine; assert a failed row containing 'Refused' and a retry | yes |
| ACT-14 | medium | A force send writes the setpoint, then WSetRvrtTms (duration or 7200 s), then reads back WSetEna; not 1 means failed | actions | publish/command_handler.py:491-524 |
tests/unit/test_dispatch_verification.py::test_handler_accepted_but_not_enabled_is_failed |
— | yes |
| ACT-17 | medium | params.duration_s is clamped to 0-7200 s and enforced by the handler's watchdog, which releases at expiry | actions | publish/command_handler.py:367-372, publish/command_handler.py:966-976 |
tests/unit/test_background_loop_guards.py::test_watchdog_survives_a_failed_check |
unit — stage 9000; assert watchdog_s == 7200 | yes |
| ACT-20 | medium | Force Standby sends 0 W and is never refused for an untrusted rating | actions | publish/command_handler.py:429-430, gateway/scheduler.py:330-331 |
tests/unit/test_force_commands.py::test_force_standby_is_zero_watts |
— | yes |
| ACT-22 | medium | Mode accepts Emergency Backup, Self-Consumption, TOU and the alias Time of Use; reserve pct is clamped to 0-100 | actions | publish/command_handler.py:97-102, publish/command_handler.py:840-846, publish/command_handler.py:881-882 |
tests/unit/test_command_result.py::test_mode_write_success |
unit — reserve 150 is written as 100 | yes |
| ACT-25 | medium | A mode write the aGate ignores marks the gateway mode-write-locked; the next successful write clears it | actions | publish/command_handler.py:862-868 |
tests/unit/test_command_result.py::test_mode_write_ignored_marks_gateway_write_locked |
— | yes |
| CND-01 | medium | A sensor with no value makes every operator false, including != and not_in | conditions | gateway/scheduler_conditions.py:227-230 |
tests/unit/test_scheduler_conditions.py::test_none_live_value_fails_closed_all_ops |
— | yes |
| CND-05 | medium | <, <=, >=, > compare numbers; text, bool or missing values make them false | conditions | gateway/scheduler_conditions.py:256-274 |
tests/unit/test_scheduler_conditions.py::test_numeric_operators |
— | yes |
| CND-06 | medium | == and != compare numerically when both sides are numbers (strings included) and exactly otherwise; == on text is case-sensitive | conditions | gateway/scheduler_conditions.py:105-113, gateway/scheduler_conditions.py:231-234 |
tests/unit/test_scheduler_conditions.py::test_enum_string_equality |
— | yes |
| CND-07 | medium | between is inclusive and order-independent; a missing second bound is false | conditions | gateway/scheduler_conditions.py:269-274 |
tests/unit/test_scheduler_conditions.py::test_between_inclusive_and_orderless |
— | yes |
| CND-08 | medium | in / not_in take numbers and inclusive ranges (a-b, a..b, en dash), comma, semicolon or newline separated, brackets ignored; a text live value is false for both | conditions | gateway/scheduler_conditions.py:116-202, gateway/scheduler_conditions.py:235-246 |
tests/unit/test_condition_in_like.py::test_ranges_and_exact_levels_mix_in_one_list |
— | yes |
| CND-09 | medium | An empty list matches nothing: in is false and not_in is true | conditions | gateway/scheduler_conditions.py:239-246 |
tests/unit/test_condition_in_like.py::test_an_empty_list_matches_nothing |
— | yes |
| CND-10 | medium | matchlist compares whole text values case-insensitively, with no range parsing | conditions | gateway/scheduler_conditions.py:247-252 |
tests/unit/test_condition_in_like.py::test_matchlist_ignores_case |
— | yes |
| CND-11 | medium | like uses * or % for many and ? or _ for one character, case-insensitive; no wildcard means contains | conditions | gateway/scheduler_conditions.py:205-224 |
tests/unit/test_condition_in_like.py::test_both_wildcard_dialects_work |
— | yes |
| CND-12 | medium | matchlist and like on a number compare its text form; on a bool they compare 'True'/'False' case-insensitively | conditions | gateway/scheduler_conditions.py:247-255 |
tests/unit/test_condition_in_like.py::test_matchlist_on_a_number_compares_its_text |
unit — grid.connected True matchlist 'true' passes | yes |
| CND-14 | medium | A Lookup row compares with another sensor's live value; a missing Lookup value is false | conditions | gateway/scheduler_conditions.py:291-302 |
tests/unit/test_scheduler_conditions.py::test_lookup_rhs_unavailable_fails_closed |
— | yes |
| CND-16 | medium | ALL needs every item true (empty ALL is true); ANY needs one (empty ANY is false); ALL is the default and the match word is case-insensitive | conditions | gateway/scheduler_conditions.py:325-332 |
tests/unit/test_scheduler_conditions.py::test_empty_all_is_true_empty_any_is_false |
— | yes |
| CND-17 | medium | A group counts as one item of its parent; groups nest to any depth in the engine | conditions | gateway/scheduler_conditions.py:278-285, gateway/scheduler_conditions.py:325-332 |
tests/unit/test_scheduler_conditions.py::test_nested_tree |
— | yes |
| CND-23 | medium | With entry_hold_s > 0 the entry fires only after being eligible continuously for that long; waiting is audited once | conditions | gateway/scheduler.py:1021-1037 |
tests/unit/test_scheduler.py::test_dwell_holds_then_fires |
— | yes |
| CND-24 | medium | A failing gate resets the hold-for timer | conditions | gateway/scheduler.py:1006-1008 |
tests/unit/test_scheduler.py::test_dwell_resets_when_conditions_flap |
— | yes |
| CND-26 | medium | entry_hold_s 0 fires immediately; the API accepts 0 to 86400 s | conditions | api/schedules_api.py:128, gateway/scheduler.py:1022 |
tests/unit/test_scheduler.py::test_zero_dwell_fires_immediately |
— | yes |
| CND-28 | medium | Entry conditions are only checked before a new dispatch; a running entry keeps the gateway when its gate turns false | conditions | gateway/scheduler.py:779-784, gateway/scheduler.py:984-989 |
tests/unit/test_scheduler_engine.py::test_incumbent_keeps_the_gateway_when_its_gate_blips |
— | yes |
| CND-33 | medium | HA states coerce to number, true (on/true/home/open/locked), false (off/false/away/closed/unlocked) or text; unavailable/unknown/none/empty become no value; only exposed entities are included | conditions | gateway/ha.py:55-80, gateway/ha.py:329-335 |
tests/unit/test_ha.py::test_coerce_state |
— | yes |
| CON-04 | medium | Entries with action none run on their own lane per entry and never compete with battery entries for a gateway | concepts | gateway/scheduler.py:722-733 |
tests/unit/test_scheduler_engine.py::test_ha_only_entry_does_not_starve_a_battery_entry |
— | yes |
| EXT-01 | medium | When the entry's window ends the owned run is released and audited ok 'window exit — released to native' | exit-and-conflicts | gateway/scheduler.py:835-884 |
tests/unit/test_scheduler.py::test_release_on_window_exit |
— | yes |
| EXT-02 | medium | A run that ends inside its window (time limit, target SoC, external release, overshoot) is audited expired and the entry does not re-fire until the window is re-entered | exit-and-conflicts | gateway/scheduler.py:937-953 |
tests/unit/test_scheduler.py::test_watchdog_release_does_not_refire_within_window |
— | yes |
| EXT-03 | medium | The done-for-this-window mark is cleared when the window closes, so the entry fires again in its next window | exit-and-conflicts | gateway/scheduler.py:845, gateway/scheduler.py:956-957 |
tests/unit/test_scheduler.py::test_refires_after_window_re_enter |
— | yes |
| EXT-04 | medium | Disabling or deleting an entry removes it from the engine; a run it owned is released on the reload tick when no other entry is active on that target | exit-and-conflicts | gateway/scheduler.py:735-753, gateway/scheduler.py:456-461 |
tests/unit/test_scheduler.py::test_deleted_entry_releases_owned_target |
— | yes |
| EXT-07 | medium | Exit conditions are evaluated only while a sustained run is owned and the handler is active, so an exit already true at fire time still dispatches and ends the run on the next tick | exit-and-conflicts | gateway/scheduler.py:896-905 |
tests/unit/test_scheduler_engine.py::test_first_tick_dispatches_even_if_exit_already_true |
— | yes |
| EXT-08 | medium | Met exit conditions release with the entry's release policy, audit exit_condition_met, run exit HA actions and block re-fire in the window | exit-and-conflicts | gateway/scheduler.py:902-918 |
tests/unit/test_scheduler_engine.py::test_exit_condition_releases_and_audits |
— | yes |
| EXT-11 | medium | release_policy release sends a plain Release with no mode command | exit-and-conflicts | gateway/scheduler.py:867-881, gateway/scheduler.py:1414-1428 |
tests/unit/test_scheduler_engine.py::test_exit_release_policy_plain_release_no_mode_command |
— | yes |
| EXT-12 | medium | restore_prior_mode releases and only audits the mode change between dispatch and release; it never writes a mode | exit-and-conflicts | gateway/scheduler.py:343-360, gateway/scheduler.py:1429-1433 |
tests/unit/test_scheduler_engine.py::test_exit_restore_prior_mode_leaves_mode_changed_mid_window |
— | yes |
| EXT-13 | medium | set_operating_mode: |
exit-and-conflicts | gateway/scheduler.py:1434-1446 |
tests/unit/test_scheduler_engine.py::test_exit_set_operating_mode_skips_write_locked_gateway |
— | yes |
| EXT-14 | medium | v2 entries apply release_policy on window/duration exit, exit conditions and Stop | exit-and-conflicts | gateway/scheduler.py:631-640, gateway/scheduler.py:871-879, gateway/scheduler.py:906 |
tests/unit/test_scheduler_engine.py::test_duration_elapsed_restore_prior_mode_does_not_rewrite_mode |
unit — Stop on a restore_prior_mode entry; assert the stopped detail carries the mode note | yes |
| EXT-17 | medium | A release reads WSetEna back; still 1 records release_unverified; the staged target SoC is cleared | exit-and-conflicts | publish/command_handler.py:687-739 |
tests/unit/test_dispatch_verification.py::test_handler_release_that_leaves_wset_enabled_is_not_verified |
— | yes |
| EXT-18 | medium | With release=hold the window end sends Force Standby and keeps ownership instead of releasing | exit-and-conflicts | gateway/scheduler.py:852-866 |
tests/unit/test_scheduler.py::test_hold_keeps_standby_across_gap |
— | yes |
| IDX-03 | medium | Saving a running entry asks to confirm; a changed action or parameter re-dispatches on the reload tick, new gates and exits apply from the next tick | index | static/js/schedule_tab.js:1395-1407, gateway/scheduler.py:984-989 |
tests/unit/test_scheduler.py::test_power_change_redispatches |
— | yes |
| OUT-04 | medium | Failed sends retry at 15 s, 30 s, then every 60 s; the first failure and the window-end failure are notified | outcomes | gateway/scheduler.py:61, gateway/scheduler.py:991-997, gateway/scheduler.py:1234-1253 |
tests/unit/test_dispatch_verification.py::test_t6_every_attempt_fails_bounded_and_one_final_failure_at_window_end |
— | yes |
| OUT-05 | medium | A successful retry is audited ok with 'recovered after N failed attempt(s)' and notified as Recovered | outcomes | gateway/scheduler.py:1047, gateway/scheduler.py:1086-1093 |
tests/unit/test_dispatch_verification.py::test_t1_failed_send_is_failed_not_ok_and_retried_with_backoff |
— | yes |
| OUT-07 | medium | Supervision uses only ok-quality points no older than 45 s; otherwise it does nothing that tick | outcomes | gateway/scheduler.py:1255-1268, gateway/scheduler.py:1280-1282 |
tests/unit/test_dispatch_verification.py::test_t3_stale_points_do_not_trigger_verification_or_reassert |
— | yes |
| OUT-08 | medium | A lost setpoint (WSetEna 0) is re-sent up to 3 times per run, then audited failed and notified once | outcomes | gateway/scheduler.py:1285-1314 |
tests/unit/test_dispatch_verification.py::test_t4_lost_setpoint_is_reasserted_then_capped |
— | yes |
| OUT-11 | medium | A not-moving battery gets one re-send; still not moving 90 s later is final not_effective with a NOT EFFECTIVE notification | outcomes | gateway/scheduler.py:1346-1373 |
tests/unit/test_dispatch_verification.py::test_t2_not_effective_resends_once_then_notifies |
— | yes |
| OUT-17 | medium | After 20 s a battery above 1.25 x request + 500 W is released (reason overshoot) and the rating for that direction is contradicted | outcomes | publish/command_handler.py:90-92, publish/command_handler.py:1024-1056 |
tests/unit/test_battery_rating.py::test_overshoot_guard_releases_and_revokes_trust |
— | yes |
| OUT-20 | medium | Run now checks entry conditions per gateway unless force=true, sends the action, audits executed or gated and returns fired, gated or noop | outcomes | gateway/scheduler.py:549-608, api/schedules_api.py:516-526 |
tests/unit/test_scheduler_engine.py::test_execute_respects_entry_conditions_unless_forced |
— | yes |
| OUT-24 | medium | Stop releases every owned gateway with the release policy, runs exit HA actions, audits stopped and blocks re-fire until the window re-opens | outcomes | gateway/scheduler.py:610-659 |
tests/unit/test_scheduler_engine.py::test_stop_entry_releases_and_blocks_refire |
— | yes |
| PRI-02 | medium | The winner is the incumbent, else the first entry in priority/age order whose gate passes, else the top-ranked active entry (which audits its gate) | priorities | gateway/scheduler.py:755-802 |
tests/unit/test_scheduler_engine.py::test_gated_battery_entry_steps_aside_for_the_next_one |
— | yes |
| PRI-07 | medium | Foreign control is a handler-active command that this target key does not own (or whose action differs from the owner's) | priorities | gateway/scheduler.py:959-963 |
tests/unit/test_scheduler.py::test_conflict_defer_leaves_manual_alone |
— | yes |
| PRI-08 | medium | defer and wait leave a manual command alone, drop stale ownership and audit deferred once | priorities | gateway/scheduler.py:964-980 |
tests/unit/test_scheduler.py::test_conflict_defer_leaves_manual_alone |
unit — conflict wait behaves like defer | yes |
| PRI-09 | medium | override replaces the manual command; the handler logs command_superseded | priorities | gateway/scheduler.py:980-981, publish/command_handler.py:553-561 |
tests/unit/test_scheduler.py::test_conflict_override_preempts_manual |
— | yes |
| RST-01 | medium | Graceful shutdown releases an active command with reason shutdown using the fast release (no handshake ramp-down) | restarts-and-clock | publish/command_handler.py:1057-1067, publish/command_handler.py:701-707 |
tests/unit/test_force_commands.py::test_fast_release_passes_zero_handshake |
— | yes |
| RST-03 | medium | A freshly started engine has no memory, so an entry whose window is open dispatches on the first tick | restarts-and-clock | gateway/scheduler.py:406-452, gateway/scheduler.py:661-664 |
tests/unit/test_scheduler.py::test_dispatch_on_window_enter |
VM/mock — restart the bridge during an open window on a mock; expect a startup release then a new dispatch | yes |
| SEN-01 | medium | time.hour, time.dow (0 = Monday) and time.month come from the bridge's local clock | conditions | gateway/scheduler_sensors.py:909-911 |
tests/unit/test_scheduler_sensors.py::test_time_sensors |
— | yes |
| SEN-03 | medium | battery, solar, grid, load and mode sensors map the gateway's cached poll points; non-numeric values read as no value | conditions | gateway/scheduler_sensors.py:442-508 |
tests/unit/test_scheduler_sensors.py::test_snapshot_maps_canonical_keys |
— | yes |
| SEN-04 | medium | grid.connected uses connection_state, else grid mode; pv.is_generating is true above 50 W | conditions | gateway/scheduler_sensors.py:82-92, gateway/scheduler_sensors.py:205-214 |
tests/unit/test_scheduler_sensors.py::test_grid_connected_prefers_connection_state_over_grid_mode |
— | yes |
| SEN-05 | medium | battery.reserve_pct is the active reserve; the per-mode reserves have a value only while that mode is active | conditions | gateway/scheduler_sensors.py:188-204, gateway/scheduler_sensors.py:450-470 |
tests/unit/test_scheduler_audit_reserve.py::test_reserve_current_follows_mode |
— | yes |
| SEN-06 | medium | ETA sensors use capacity, SoC, the constants and the 702 nameplate rate (or current power for _now, None when idle or the wrong way); 0 when already there | conditions | gateway/scheduler_sensors.py:216-322 |
tests/unit/test_constants.py::test_realtime_eta_variants |
— | yes |
| SEN-07 | medium | const.* default to 20, 100 and 30 % and always have a value | conditions | gateway/constants.py:25-27, gateway/scheduler_sensors.py:578-589 |
tests/unit/test_constants.py::test_defaults_and_persist |
— | yes |
| SEN-08 | medium | Tariff window sensors come from the first service with that feature and read False (not None) when none is configured | conditions | gateway/scheduler_sensors.py:336-363, gateway/billing.py:85-95 |
tests/unit/test_billing.py::test_billing_store_and_sensor |
unit — no service: tariff.demand_window_active == 0 evaluates true | yes |
| SEN-09 | medium | tariff.import_billable is false only in a free window or at a zero rate, and None when unconfigured | conditions | gateway/scheduler_sensors.py:160-181 |
tests/unit/test_dispatch_sensors.py::test_unconfigured_is_unknown_not_free |
— | yes |
| SEN-10 | medium | tariff.spot_price and feed_in_price are c/kWh from the NEM cache or an HA entity and read None when older than 30 min | conditions | gateway/aemo_nem.py:58, gateway/aemo_nem.py:360-385 |
tests/unit/test_aemo_nem.py::test_cached_flags_stale_and_ages_out |
— | yes |
| SEN-11 | medium | service.* plan permissions default to permitted (True) when unset | conditions | gateway/scheduler_sensors.py:640-668 |
tests/unit/test_billing.py::test_plan_permissions_default_permissive |
— | yes |
| SEN-13 | medium | weather.* and solar_forecast.* read the cached Open-Meteo forecast and are None when no location is set | conditions | gateway/scheduler_sensors.py:873-907, gateway/solar_forecast.py:369-373 |
tests/unit/test_solar_forecast.py::test_registry_sensors_none_when_off_and_listed_with_units |
— | yes |
| SEN-17 | medium | energy period sensors map energy_ |
conditions | gateway/scheduler_sensors.py:943-973 |
tests/unit/test_energy_totals.py::test_period_sensors_registered |
— | yes |
| TGT-04 | medium | A fan-out target is reconciled per member gateway with ownership keyed (type, id, gateway) | targets | gateway/scheduler.py:412-416, gateway/scheduler.py:724-727 |
tests/unit/test_scheduler.py::test_fanout_ownership_is_per_gateway |
— | yes |
| TGT-05 | medium | At window exit every member gateway the entry owns is released | targets | gateway/scheduler.py:835-884 |
tests/unit/test_scheduler.py::test_fanout_releases_all_on_window_exit |
— | yes |
| TRG-01 | medium | A fire-based entry is active during [previous fire, previous fire + duration_s) | triggers | gateway/scheduler.py:180-199, gateway/scheduler_triggers.py:387-411 |
tests/unit/test_scheduler_engine.py::test_trigger_inactive_after_duration_window |
— | yes |
| TRG-04 | medium | A recurring window is active from start up to but not including end | triggers | gateway/scheduler.py:120-138 |
tests/unit/test_scheduler.py::test_inactive_outside_window |
— | yes |
| TRG-05 | medium | An empty days list means every day; a weekday not in the list is inactive | triggers | gateway/scheduler.py:97, gateway/scheduler.py:135-136 |
tests/unit/test_scheduler.py::test_empty_days_means_every_day |
— | yes |
| TRG-07 | medium | A window whose end is earlier than its start wraps past midnight | triggers | gateway/scheduler.py:120-126 |
tests/unit/test_scheduler.py::test_window_wraps_past_midnight |
— | yes |
| TRG-11 | medium | A one-off date entry is active only on its date and ignores the days list | triggers | gateway/scheduler.py:104-117, gateway/scheduler.py:129-136 |
tests/unit/test_scheduler.py::test_dated_entry_ignores_days_field |
— | yes |
| TRG-12 | medium | A one-off date entry never fires after its date; Next fire is empty and the list says Once (passed) | triggers | gateway/scheduler.py:153-160, static/js/schedule_tab.js:853-856 |
tests/unit/test_scheduler.py::test_dated_entry_does_not_refire_next_week_same_weekday |
— | yes |
| TRG-14 | medium | Daily fires every day at time_of_day; an invalid time never fires | triggers | gateway/scheduler_triggers.py:88-97, gateway/scheduler_triggers.py:276-284 |
tests/unit/test_scheduler_triggers.py::test_prev_daily_today_vs_yesterday |
— | yes |
| TRG-17 | medium | Interval fires at anchor + k*every, counted from the most recent anchor time | triggers | gateway/scheduler_triggers.py:118-137, gateway/scheduler_triggers.py:305-321 |
tests/unit/test_scheduler_triggers.py::test_interval_explicit_anchor_time |
— | yes |
| TRG-21 | medium | Monthly fires on day (clamped to the month length) at time_of_day in the listed months; empty months = all 12 | triggers | gateway/scheduler_triggers.py:140-192 |
tests/unit/test_scheduler_triggers_cron.py::test_monthly_day_clamps_to_month_length |
— | yes |
| TRG-26 | medium | Cron previous/next fire includes the current minute when it matches | triggers | gateway/scheduler_triggers.py:216-217, gateway/scheduler_triggers.py:227-228 |
tests/unit/test_scheduler_triggers_cron.py::test_cron_next_and_prev |
— | yes |
| TRG-27 | medium | An always entry is continuously active and dispatches when its entry conditions pass | triggers | gateway/scheduler.py:189-191 |
tests/unit/test_scheduler_engine.py::test_always_trigger_gated_by_entry_conditions |
— | yes |
| TRG-37 | medium | On recovery the catch-up pass runs for that gateway and links missed and late-fired entries to the outage record | triggers | gateway/connectivity.py:79-93, main.py:701-707 |
tests/unit/test_connectivity_monitor.py::test_recovery_runs_catchup_and_links_outage |
— | yes |
| TRG-38 | medium | At start-up, a persisted last-good-poll older than 60 s records a restart_downtime outage and runs catch-up from that time | triggers | gateway/connectivity.py:113-140, main.py:979-985 |
tests/unit/test_connectivity_monitor.py::test_startup_catchup_runs_for_downtime_gap |
— | yes |
| TRG-39 | medium | Catch-up skips entries whose window is open now and entries not targeting the recovered gateway | triggers | gateway/scheduler.py:495-504 |
tests/unit/test_scheduler_engine.py::test_catchup_skips_still_open_window |
— | yes |
| TRG-43 | medium | late_fire_always checks the entry conditions now and, if they pass, sends the action with catchup_duration_s or duration_s staged as its time limit | triggers | gateway/scheduler.py:517-541 |
tests/unit/test_scheduler_engine.py::test_catchup_late_fire_always_gated_by_entry_conditions |
— | yes |
| ACT-27 | low | Action none sends no battery command and runs only the HA actions, on its own lane | actions | gateway/scheduler.py:1211-1212, gateway/scheduler.py:722-733 |
tests/unit/test_scheduler.py::test_none_action_fires_ha_only |
— | no |
| CLK-03 | low | The timezone is recorded at install and compared at every start; a mismatch or a likely container default (UTC) is reported for confirmation | restarts-and-clock | config/clock.py:96-150, config/clock.py:175-217 |
tests/unit/test_clock_guard.py::test_drift_to_utc_is_reported |
— | no |
| CND-02 | low | An unknown sensor id or operator evaluates false; neither is validated at save | conditions | gateway/scheduler_conditions.py:275, gateway/scheduler_conditions.py:289, api/schedules_api.py:123-124 |
tests/unit/test_scheduler_conditions.py::test_unknown_operator_fails_closed |
unit (API) — POST an entry with op 'approx' and assert 201 (documents the gap) | no |
| CND-03 | low | Every condition in a tick reads one snapshot per gateway built from cached points; every static sensor id is present, HA ids pass through | conditions | gateway/scheduler.py:1464-1480, gateway/scheduler_sensors.py:975-988 |
tests/unit/test_scheduler_sensors.py::test_snapshot_always_contains_every_sensor_id |
— | no |
| CND-18 | low | The evaluator visits every item without short-circuit and returns a trace row per condition | conditions | gateway/scheduler_conditions.py:325-332 |
tests/unit/test_scheduler_conditions.py::test_trace_shape_and_no_short_circuit |
— | no |
| CND-29 | low | A failing gate is audited once per window as gated, listing up to four failing leaves with live values | conditions | gateway/scheduler.py:1003-1019, gateway/scheduler.py:265-276 |
tests/unit/test_scheduler_engine.py::test_gate_audits_once_while_gated_then_dispatches_when_it_passes |
— | no |
| CND-30 | low | Test Verification posts the block to /api/scheduler/evaluate for the target gateway (default for site/service) and maps each trace row back to its condition | conditions | api/scheduler_api.py:121-129, static/js/schedule_tab.js:1369-1388 |
tests/unit/test_api.py::test_scheduler_evaluate_endpoint |
— | no |
| CND-34 | low | HA entity values update from WebSocket state_changed events, with a 30 s REST poll fallback | conditions | gateway/ha.py:48, gateway/ha.py:135-140, gateway/ha.py:268-326 |
tests/unit/test_ha.py::test_seed_and_apply_state_changed |
— | no |
| CND-35 | low | Import warns about unknown sensors and ha: sensors whose instance is missing | conditions | api/schedules_api.py:406-418 |
tests/unit/test_schedules_v2_api.py::test_import_dry_run_reports_warnings_and_errors |
— | no |
| CON-02 | low | Only enabled entries are loaded, ordered priority descending, then created_at ascending (oldest first), then id | concepts | gateway/scheduler.py:456-461, store/db.py:1772-1788 |
tests/unit/test_scheduler.py::test_store_sorted_by_priority |
unit — add an equal-priority pair and assert the older one sorts first | no |
| CON-03 | low | Conditions read a per-tick snapshot of the gateway's cached points; with no points source every sensor is None and conditions fail closed | concepts | gateway/scheduler.py:1464-1480, gateway/scheduler.py:702 |
tests/unit/test_scheduler_engine.py::test_entry_conditions_fail_closed_without_points_source |
— | no |
| HAA-03 | low | Fire-time HA actions run once per activation, after the battery action | actions | gateway/scheduler.py:1047-1056 |
tests/unit/test_scheduler.py::test_ha_actions_fire_once_per_activation |
— | no |
| HAA-04 | low | Fire-time HA actions do not run while the entry is gated | actions | gateway/scheduler.py:1003-1018 |
tests/unit/test_scheduler.py::test_ha_actions_skipped_when_gated |
— | no |
| HAA-05 | low | when=both runs in the fire phase and in the exit phase; a missing when defaults to fire | actions | gateway/scheduler.py:1136-1142 |
tests/unit/test_scheduler.py::test_ha_actions_fire_and_exit_phases |
— | no |
| HAA-06 | low | Exit-time HA actions run once when the activation ends: window close, exit condition, in-window end, or Stop | actions | gateway/scheduler.py:850, gateway/scheduler.py:917, gateway/scheduler.py:952, gateway/scheduler.py:644, gateway/scheduler.py:1202-1206 |
tests/unit/test_scheduler.py::test_exit_ha_action_on_exit_condition_met |
unit — exit actions on window close and on Stop | no |
| HAA-08 | low | An HA action guard is one condition evaluated at run time; false skips the action with a gated audit row | actions | gateway/scheduler.py:1122-1126, gateway/scheduler.py:1168-1175 |
tests/unit/test_scheduler.py::test_ha_action_guard_gates_the_action |
— | no |
| HAA-09 | low | %group.name% placeholders in notify title/message are filled from the snapshot; floats print with %g; unknown or empty prints ? | actions | gateway/scheduler.py:1095-1120 |
tests/unit/test_scheduler_engine.py::test_notification_substitutes_live_sensor_values |
— | no |
| HAA-11 | low | Failure, not-effective and recovery alerts go to every notify target on the entry with title FranklinWH bridge; none configured is audited skipped | actions | gateway/scheduler.py:1390-1412 |
tests/unit/test_dispatch_verification.py::test_no_notify_target_is_audited_not_silent |
— | no |
| HAA-12 | low | Each HA action is audited ha_action or failed with the rendered detail; with no HA hooks one failed row reports the skip | actions | gateway/scheduler.py:1148-1153, gateway/scheduler.py:1191-1200 |
tests/unit/test_scheduler.py::test_ha_actions_no_dispatcher_is_safe |
— | no |
| IDX-05 | low | Export returns a franklinwh-automations v1 bundle of portable fields; import is a dry run unless dry_run=false and creates entries disabled | index | api/schedules_api.py:424-493 |
tests/unit/test_schedules_v2_api.py::test_export_bundle_shape |
— | no |
| IDX-06 | low | Timeline segments: enabled entries only, fire+duration at least 8 min, interval capped at 96, monthly/cron/always not drawn | index | gateway/scheduler_triggers.py:324-384, api/schedules_api.py:543-595 |
tests/unit/test_scheduler_triggers.py::test_day_segments_min_width_for_zero_duration |
— | no |
| IDX-07 | low | One-off date windows and API oneoff triggers are drawn on the matching weekday in any week, also after the date has passed | index | api/schedules_api.py:585-590, gateway/scheduler_triggers.py:374-382 |
tests/unit/test_scheduler_triggers.py::test_day_segments_oneoff_on_matching_weekday_only |
unit (API) — timeline for a past one-off date's weekday still returns its segment (documents the preview gap) | no |
| OUT-01 | low | Create, update, enable, disable, delete and import are audited to the Activity Log with the changed fields | outcomes | api/schedules_api.py:203-260 |
tests/unit/test_schedules_v2_api.py::test_crud_changes_are_audited |
— | no |
| OUT-02 | low | GET /api/schedules/log filters by schedule_id and status (the audit result), newest first, limit 1-500 | outcomes | api/schedules_api.py:496-513, store/db.py:1924-1950 |
tests/unit/test_schedules_v2_api.py::test_log_filter_by_status |
— | no |
| OUT-09 | low | 90 s after the send the battery must move the commanded way at >= max(100 W, 20 % of request) (standby within 200 W) to be verified | outcomes | gateway/scheduler.py:1316-1345 |
tests/unit/test_dispatch_verification.py::test_t2_moving_battery_is_verified_once_with_no_extra_writes |
— | no |
| OUT-10 | low | A charge at >= 98 % SoC or a discharge within 3 % of the reserve is audited unverifiable, not not_effective | outcomes | gateway/scheduler.py:1330-1336, gateway/scheduler.py:1380-1388 |
tests/unit/test_dispatch_verification.py::test_t7_soc_limit_is_not_judged_as_not_effective |
— | no |
| OUT-13 | low | The command handler writes one dispatch_outcomes row per battery command (sent or failed) and moves it to released, release_unverified or superseded | outcomes | store/dispatch_outcomes.py:1-31, publish/command_handler.py:526-535, publish/command_handler.py:648-676, publish/command_handler.py:725-730 |
tests/unit/test_dispatch_verification.py::test_handler_new_command_supersedes_the_active_row |
— | no |
| OUT-16 | low | GET /api/gateways/{gw}/dispatch-outcomes lists that gateway's rows newest first | outcomes | api/gateways_api.py:1298-1308 |
tests/unit/test_dispatch_verification.py::test_dispatch_outcomes_endpoint |
— | no |
| OUT-18 | low | The five alert messages are sent from the failure, window-end, setpoint-lost, not-effective and recovery paths | outcomes | gateway/scheduler.py:1250-1253, gateway/scheduler.py:843-844, gateway/scheduler.py:1294, gateway/scheduler.py:1372, gateway/scheduler.py:1090-1093 |
tests/unit/test_dispatch_verification.py::test_t2_not_effective_resends_once_then_notifies |
— | no |
| PRI-06 | low | Entries that lose the gateway are audited skipped once per contest (a new winner starts a new contest) | priorities | gateway/scheduler.py:804-829 |
tests/unit/test_scheduler_engine.py::test_gated_battery_entry_steps_aside_for_the_next_one |
— | no |
| PRI-14 | low | Scheduled, Run now and late-fire sends carry requester schedule: |
priorities | gateway/scheduler.py:1208-1230, publish/command_handler.py:314-330, publish/command_handler.py:530-532, api/gateways_api.py:1206, publish/mqtt_publisher.py:881 |
tests/unit/test_dispatch_verification.py::test_command_records_who_sent_it |
— | no |
| SEN-02 | low | time.utc_offset_h is the clock's UTC offset; time.tz_ok is None before the first startup check, then 1 or 0 | conditions | gateway/scheduler_sensors.py:913-933, config/clock.py:79-94 |
tests/unit/test_clock_guard.py::test_tz_ok_is_none_before_any_check |
— | no |
| SEN-12 | low | service.tz_matches_clock is 0 when the plan's timezone differs from the bridge clock | conditions | gateway/scheduler_sensors.py:109-130, gateway/scheduler_sensors.py:805-810 |
tests/unit/test_service_location.py::test_clock_on_utc_while_plan_is_sydney_fails |
— | no |
| SEN-15 | low | dispatch.* report schedule (an owned run), manual (WSetEna 1 without an owner) or none; the snapshot mapping is tested, the engine side is not | conditions | gateway/scheduler.py:1491-1535, gateway/scheduler_sensors.py:672-697 |
tests/unit/test_dispatch_sensors.py::test_schedule_in_control_is_named |
unit — engine with an owned run: dispatch_points() returns source schedule and the entry name | no |
| TGT-06 | low | Activity Log target is the gateway's name with (mock) for a mock gateway | targets | gateway/scheduler.py:1552-1564, main.py:683-689 |
tests/unit/test_scheduler_audit_reserve.py::test_target_label_uses_gateway_name_and_mock_flag |
— | no |
| TGT-14 | low | Import re-targets an unknown gateway id to default and creates the entry disabled | targets | api/schedules_api.py:467-470 |
tests/unit/test_schedules_v2_api.py::test_import_creates_disabled_with_gateway_fallback |
— | no |
| TRG-03 | low | Fire times are computed at minute resolution; a fire at the current minute counts as now | triggers | gateway/scheduler_triggers.py:241-243, gateway/scheduler_triggers.py:395-397 |
tests/unit/test_scheduler_triggers.py::test_daily_exactly_now |
— | no |
| TRG-09 | low | A window with start equal to end is never active | triggers | gateway/scheduler.py:122-123 |
tests/unit/test_scheduler.py::test_end_equals_start_matches_nothing |
— | no |
| TRG-16 | low | A weekly trigger with no days never fires and is not rejected at save | triggers | gateway/scheduler_triggers.py:100-115, api/schedules_api.py:154-179 |
tests/unit/test_scheduler_triggers.py::test_weekly_empty_days_returns_none |
unit (API) — POST weekly with days_of_week [] and assert 201 with next_fire null (documents the gap) | no |
| TRG-20 | low | An interval of 0, negative or non-numeric seconds never fires; it is not rejected by the API | triggers | gateway/scheduler_triggers.py:118-124, static/js/schedule_tab.js:1353-1356 |
tests/unit/test_scheduler_triggers.py::test_interval_invalid_every_returns_none |
— | no |
| TRG-22 | low | The API rejects a monthly day outside 1-31 and months that are not integers 1-12 | triggers | api/schedules_api.py:169-179 |
tests/unit/test_schedules_v2_api.py::test_reject_monthly_bad_day |
unit (API) — months ["1"] (a string) is rejected with 400 | no |
| TRG-24 | low | An invalid or empty cron expression is rejected at save; at run time an invalid one never fires | triggers | api/schedules_api.py:159-168, gateway/scheduler_triggers.py:195-229 |
tests/unit/test_schedules_v2_api.py::test_create_cron_trigger_and_reject_invalid |
— | no |
| TRG-32 | low | Next fire for windows is the next window start at or after now within 8 days (a dated entry uses its date); for triggers next_fire_at; always gives none | triggers | gateway/scheduler.py:141-169, api/schedules_api.py:262-280 |
tests/unit/test_scheduler.py::test_next_fire_rolls_to_matching_weekday |
— | no |
| TRG-36 | low | An outage opens when a gateway has had no ok poll for more than 60 s | triggers | gateway/connectivity.py:41, gateway/connectivity.py:142-152 |
tests/unit/test_connectivity_monitor.py::test_outage_opens_after_threshold |
— | no |
| TRG-40 | low | A fire or window start in (outage start, now] that is no longer active is reported missed; always entries are never missed | triggers | gateway/scheduler.py:210-227 |
tests/unit/test_scheduler_engine.py::test_catchup_legacy_window_missed |
— | no |