mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
* feat: add ScheduledDrying model for delayed drying runs (#2638) * Release the printer when a scheduled dry ends (#2638) _check_scheduled_dryings marks a printer as drying in _drying_in_progress, which is shared with auto-drying. Auto-drying prunes that map in _sync_drying_state(), but that call sits behind its enabled check, and this is the first writer that runs whether auto-drying is on or not. With it off -- the default -- nothing dropped the entry short of a print being dispatched to the same printer, so the next scheduled run parked on "already_drying" forever and queue_drying_block held that printer's prints too. A nightly off-peak dry with no printing in between is exactly the workflow this feature is for: night one worked, every night after it silently did not. The check now releases what it acquired, covering both a run that ends mid-pass and one cancelled through the route between passes. The retention prune ran on every pass. Issuing the DELETE is what opens a write transaction, this method is called every 3s while the queue dispatches, and rows only become prunable a week after they finish, so it is now gated to hourly on a monotonic stamp -- with the first pass after a restart still reaping whatever the dead process left behind. Both drying paths now pick the blocking dry_sf_reason through one rule. The immediate endpoint quoted whichever code the firmware listed first while the scheduler prioritised power over retract, so one blocked AMS read two ways depending on which button you pressed. drying_preflight.primary_reason_code holds the order and both call it, including the flame button's tooltip, which had no wording for filament at the outlet at all and sent those users to the generic "can't start drying right now". scheduled_drying joins the model list in init_db. The table was already created -- importing the package registers it -- but it was the only model relying on that indirection. Tests: the release (completion and route-cancel), the prune throttle, the shared reason rule, the tooltip priority, and four driving the real check_queue, which nothing covered before -- a pass with no rows still dispatching prints, a due row dispatching, and a failed row not stalling the queue behind it. Each one fails against the code it guards. --------- Co-authored-by: MartinNYHC <martin@bambuddy.cool> Co-authored-by: maziggy <mz@v8w.de>
87 lines
3.5 KiB
Python
87 lines
3.5 KiB
Python
"""The shared drying preflight's blocked-reason rules (#2638).
|
|
|
|
An AMS can report several ``dry_sf_reason`` codes at once, and the two callers
|
|
render that differently: the immediate endpoint raises the message, the
|
|
scheduler stores a token the frontend translates. Both pick the code through
|
|
``primary_reason_code`` so one blocked AMS is not described two ways.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from backend.app.services import drying_preflight as preflight
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
class TestPrimaryReasonCode:
|
|
def test_a_single_code_is_returned_as_is(self):
|
|
assert preflight.primary_reason_code([2]) == 2
|
|
|
|
@pytest.mark.parametrize("power_code", sorted(preflight.POWER_REASON_CODES))
|
|
def test_power_outranks_a_transient_code(self, power_code):
|
|
""" "AMS is busy" clears by itself; "plug the PSU in" does not, and the
|
|
user can only act on one of them."""
|
|
assert preflight.primary_reason_code([2, power_code]) == power_code
|
|
assert preflight.primary_reason_code([power_code, 2]) == power_code
|
|
|
|
def test_power_outranks_retract(self):
|
|
assert preflight.primary_reason_code([preflight.RETRACT_REASON_CODE, 1]) == 1
|
|
|
|
def test_retract_outranks_a_transient_code(self):
|
|
assert preflight.primary_reason_code([0, preflight.RETRACT_REASON_CODE]) == preflight.RETRACT_REASON_CODE
|
|
|
|
def test_transient_codes_keep_the_reported_order(self):
|
|
assert preflight.primary_reason_code([6, 2, 4]) == 6
|
|
|
|
def test_no_codes_is_no_answer(self):
|
|
assert preflight.primary_reason_code([]) is None
|
|
|
|
|
|
class TestWaitingReason:
|
|
def test_power(self):
|
|
assert preflight.waiting_reason_for_codes([2, 8]) == preflight.WAITING_REASON_POWER
|
|
|
|
def test_retract(self):
|
|
assert preflight.waiting_reason_for_codes([2, 3]) == preflight.WAITING_REASON_RETRACT
|
|
|
|
def test_transient_falls_through_to_the_generic_token(self):
|
|
assert preflight.waiting_reason_for_codes([2]) == preflight.WAITING_REASON_BLOCKED
|
|
|
|
def test_no_codes_is_not_a_specific_reason(self):
|
|
"""Callers only ask when something is blocking, but answering with a
|
|
power alert for an empty list would be worse than saying nothing
|
|
specific."""
|
|
assert preflight.waiting_reason_for_codes([]) == preflight.WAITING_REASON_BLOCKED
|
|
|
|
|
|
class TestBothPathsAgree:
|
|
"""The endpoint quotes a message and the scheduler stores a token. Whatever
|
|
they pick, it has to be the same code underneath."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"codes,expected_token",
|
|
[
|
|
([2, 1], preflight.WAITING_REASON_POWER),
|
|
([2, 8], preflight.WAITING_REASON_POWER),
|
|
([0, 3], preflight.WAITING_REASON_RETRACT),
|
|
([6, 2], preflight.WAITING_REASON_BLOCKED),
|
|
],
|
|
)
|
|
def test_the_message_and_the_token_describe_one_code(self, codes, expected_token):
|
|
code = preflight.primary_reason_code(codes)
|
|
|
|
# What the immediate endpoint raises.
|
|
assert code in preflight.DRY_SF_REASON_MESSAGES
|
|
# What the scheduled row records, for the same code.
|
|
assert preflight.waiting_reason_for_codes(codes) == expected_token
|
|
|
|
|
|
class TestBlockingReasonCodes:
|
|
def test_unknown_and_malformed_codes_are_dropped(self):
|
|
"""A firmware that grows a code we have no message for must not block a
|
|
run with an unexplainable reason."""
|
|
assert preflight.blocking_reason_codes({"dry_sf_reason": [2, 99, "x", None]}) == [2]
|
|
|
|
def test_a_missing_unit_is_not_blocking(self):
|
|
assert preflight.blocking_reason_codes(None) == []
|