Skip to content

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: releases then writes the mode unless it is already in place or the gateway is mode-write-locked; a failed write is reported in the note 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___kwh points per gateway 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:, recorded on the control log and dispatch_outcomes; arbitration does not read it 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