mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Show the plug that powers the printer in the card's Power row (#2830)
A printer card has one Power row: a plug name, its draw, and the auto-off and on/off buttons. Which plug filled it was decided by nothing -- the endpoint returned the first row the database handed back that was not a Home Assistant script, from a query with no ORDER BY. For the reporter that was an enclosure exhaust fan, added before the outlet their X1C is plugged into. The card showed the fan's name with '--' for watts, offered to switch the printer off by cutting the fan, and demoted the metered outlet to the small HA button row. The fan was marked as not powering the printer and hidden from the card; neither setting was consulted here, though controls_printer_power has decided the scheduler's power-on pick since #2629. Rank the candidates instead: switchable at all, controls_printer_power, enabled, show_on_printer_card, reports power, lowest id. The first rules out a script, which can only be run, and an MQTT plug, which the control endpoint rejects as monitor-only -- and an MQTT plug is exactly the kind that reports watts, so without it ahead of the power tiebreak the row could land on a plug whose on/off button answers with an error. The last is not cosmetic: with no ORDER BY, a plain UPDATE on PostgreSQL can move a row and silently swap which plug the card calls the printer's power. None of these excludes a plug. A printer whose only plug is hidden, disabled or monitor-only still needs its Power row, because that row holds the on/off button and the HA buttons are drawn inside it. controls_printer_power sits above show_on_printer_card because the two only disagree when the plug that really feeds the printer is hidden, and letting a display preference win there points the power buttons at an accessory -- the fault #2629 fixed. Power capability is read from the configuration, not measured: this runs on every card render, and it is approximate both ways, so it only breaks a tie. The scripts endpoint shares the same pick and excludes it, so a switchable main plug is not repeated as a button directly below itself. A script is left in place: a printer whose only entities are scripts falls back to showing one in the power row, and taking it out of the button row too would cost it the one-click run it has always had.
This commit is contained in:
@@ -31,6 +31,7 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
|
||||
|
||||
### Fixed
|
||||
- **A printer with more than one plug could show the wrong one as its power, complete with the buttons that switch the printer off (#2830)** — The Power row on the printer card names a plug, shows what it is drawing, and carries the auto-off and on/off buttons. Which plug it lands on was decided by nothing at all: it took the first one the database happened to hand back that was not a Home Assistant script. For the reporter that was an enclosure exhaust fan with no power monitoring, added before the outlet the X1C is plugged into — so the card showed the fan's name with `--` where the wattage goes, offered to switch the printer off by cutting the fan, and pushed the metered outlet down into the small row of Home Assistant buttons underneath. The fan was marked as not powering the printer and hidden from the card, and both settings were ignored. They are now the first things consulted. A plug that can be switched at all comes first — a Home Assistant script can only be run, and an MQTT plug is monitor-only, so the on/off button answers either with an error — then the plug that feeds the printer, then one that is enabled, then one the user has not hidden, then one that can actually report watts, and equal plugs settle by age so the answer is the same every time — which it previously was not: the query had no ordering, so on PostgreSQL editing an unrelated field on a plug could move its row and silently change which plug the card called the printer's power. Hidden and disabled are used to rank plugs rather than to rule them out, because a printer with a single plug still needs its Power row: that row holds the on/off button, and the Home Assistant buttons are drawn inside it. Powering the printer is ranked above being visible on purpose. The two only disagree when someone hides the plug that genuinely feeds the printer, and letting the display setting win there would point the power buttons at an accessory, which is the fault #2629 fixed for automatic power-on. Whether a plug reports watts is read from how it is set up rather than measured — this runs every time a card is drawn, and asking every plug in turn would put a network round trip into it — so it only ever breaks a tie between plugs that are equal on everything else. One thing that was wrong before and would have become visible now is fixed with it: the main plug is no longer repeated as a button in the Home Assistant row directly below itself. A printer whose only entities are scripts is left exactly as it was, script button and all — there the Power row is a fallback rather than a duplicate, and reaching a script through it would cost a confirmation dialog the button never needed. Covered by backend tests.
|
||||
- **A finished job stayed in the queue at 0% and blocked everything behind it until it was cancelled by hand (#2829)** — The progress bar emptied, the job never cleared, and the next job for that printer would not start. Bambuddy has no run identifier to match a completion against, so it finds the job to close by printer and "currently printing" alone. A check added three days earlier compares the finished print's name against the file the job was sent with, so that the printer's own automatic calibration runs cannot close somebody's job early — and it compared the two names letter for letter. The printer does not send the name back the way it received it: it puts underscores where the file has spaces, so `H2D_Carbon_Filter_(V2)_Body & Solid Lid` came back as `H2D_Carbon_Filter_(V2)_Body_&_Solid_Lid`, the check called that a different print, and the job was left running forever. It also shortens long names and marks the cut, which would have done the same to anything with a long title. Both are now understood, using the same rule the rest of Bambuddy has always applied when matching these names. The check keeps doing its real job: on the same printer, on the same day, it correctly turned away a completion for the printer's own pressure-advance calibration. **If a job of yours is stuck like this, cancelling it is still the way to clear it — the fix stops it happening again rather than repairing the row.** Since the whole failure was one comparison being stricter than reality, and a stuck job is far more damaging than a job closed slightly late, the scheduler now also closes a job by itself if it is still marked as printing five minutes after the printer went quiet. That is well outside anything normal — a real completion lands within seconds — so it only ever sees a job that was already stuck, and it does not fire on a printer Bambuddy has lost contact with. Covered by backend tests.
|
||||
- **The printer card's thumbnail dropped to the placeholder after leaving the page and coming back, until the browser was reloaded (#2826, reported by @chitrangdesign)** — The thumbnail's address carries the print's name so that starting a different print fetches a different picture. That name does not change while a print runs, so returning to the printers page asked for the very same address again and the browser answered from memory without going to the network at all. The card only ever learned that its picture had arrived from the browser telling it so, and on a picture that was already in hand that announcement raced against the card's own setup, which assumed nothing had arrived yet. When the announcement came first the setup undid it, and nothing put it right afterwards, because the address stays the same for the rest of the print. The card now asks the picture whether it is showing something rather than assuming it is not, which settles the race whichever way round it happens. Being a race is also why it happened every single time for the reporter and not once on the machine it was tested on, and why nothing showed up in the browser's network panel: there was no request to show. Only the printer card was affected -- archive thumbnails use a different address every time and never had it. Covered by frontend tests.
|
||||
- **A print the printer kept on its own internal storage archived as a bare name, with no thumbnail and no reason given (#2780, reported by @Utility9298 and @AntonPalmqvist)** — Bambuddy reads a print's 3MF, cover and timelapse off the printer over FTPS on port 990, and on every Bambu model that port serves external storage only — the card or the stick. It is not a view of the printer's filesystem. Under some configurations H2-series and P2S firmware keeps the sliced file on internal storage instead, and Bambu Studio uploads it there over a separate service on port 6000, so there is nothing on FTPS to fetch at any path. **This is not every H2C or P2S** — plenty archive perfectly, and a print launched from Bambuddy rather than the slicer always does, because Bambuddy uploads over FTPS itself. What decides it is where the file ended up, and the print command says which of the two it used and always has: its `url` field reads `ftp://<name>` for the card and `brtc://emmc/<name>` for internal storage. Bambuddy ignored that field and swept anyway — six filename variants across five directories with four retries for the 3MF, sixteen more paths for the cover, then the timelapse scan, roughly 110 connections per print that could not succeed. In the reporter's support bundle every one of the 35 dispatches to their H2C and P2S said internal storage and every one of the 25 to their X1C said external, and all 44 of the empty archive cards belonged to those two printers and no others. The field is now read, the sweep is skipped when it cannot succeed, and the card says which of the two reasons applies. Nothing changes for a printer that puts its prints on the card: the sweep runs exactly as before, and it also runs whenever the answer is not known — a printer whose broker refuses the request topic never sends us a `url`, and reading that silence as bad news would break archives that work today. The answer is deliberately forgotten when the print it described ends, rather than kept as a standing fact about the printer, because plenty of prints never announce themselves at all: 14 of the 79 print starts in the reporter's bundle arrived with no command on that topic, being started from the printer's own screen or picked up after a restart. Left standing, one slicer print to internal storage would have suppressed the lookup for every screen-started print after it. **This does not make the affected prints archive in full** — reading internal storage needs the port-6000 protocol, which is tracked separately as #2762. Name, timing, status and the finish photo were never affected. Two things that were actively misleading are gone with it. The archives banner told everyone the same thing — that "Store sent files on external storage" was off in the slicer and to go and turn it on — which is wrong advice for this cause and was followed twice: the reporter's three affected printers had that setting on for the entire three weeks of the log, and turning it on again changes nothing. And the connection diagnostic reported a clean pass for a printer whose slot was empty, because it read only the toggle; an empty slot is now reported as a failure with the empty slot named, and a printer that has storage and still used internal storage is reported as a warning rather than a pass. The timelapse scan is gated more narrowly than the rest on purpose: the printer writes its video to the card itself, so where the sliced file went says nothing about whether a video exists, and only a printer that reports an empty slot skips it. Covered by backend and frontend tests.
|
||||
|
||||
@@ -138,6 +138,90 @@ async def create_smart_plug(
|
||||
return plug
|
||||
|
||||
|
||||
def _is_script_plug(plug: SmartPlug) -> bool:
|
||||
"""Whether the plug is a Home Assistant script rather than a switchable device."""
|
||||
return bool(plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."))
|
||||
|
||||
|
||||
def _can_be_switched(plug: SmartPlug) -> bool:
|
||||
"""Whether ``control_smart_plug`` can actually turn this plug on and off.
|
||||
|
||||
Two kinds cannot, and the card's on/off button is useless on both:
|
||||
|
||||
- A Home Assistant script. It can be run, not switched.
|
||||
- An MQTT plug. Bambuddy subscribes to it and never publishes, so the
|
||||
control endpoint rejects it outright as monitor-only -- and an MQTT plug
|
||||
is exactly the kind that reports watts, so without this it would win the
|
||||
power tiebreak below and take the row off a plug that can be switched.
|
||||
"""
|
||||
return not _is_script_plug(plug) and plug.plug_type != "mqtt"
|
||||
|
||||
|
||||
def _reports_power(plug: SmartPlug) -> bool:
|
||||
"""Whether the plug is configured with somewhere to read watts from (#2830).
|
||||
|
||||
Read from the configuration rather than measured: this runs on every printer
|
||||
card render, and probing each plug would mean an HTTP round trip per plug.
|
||||
So it is approximate in both directions -- an HA plug with no dedicated power
|
||||
sensor may still report watts from the switch entity's own
|
||||
``current_power_w`` attribute, and a Tasmota device without energy metering
|
||||
is counted here as if it had it. Only a live read could tell, and this is
|
||||
used solely to break a tie between plugs that are otherwise equally
|
||||
eligible, so neither miss can decide anything on its own.
|
||||
"""
|
||||
if plug.plug_type == "homeassistant":
|
||||
return bool(plug.ha_power_entity)
|
||||
if plug.plug_type == "mqtt":
|
||||
return bool(plug.mqtt_power_topic or plug.mqtt_topic)
|
||||
if plug.plug_type == "rest":
|
||||
return bool(plug.rest_power_path)
|
||||
return True # Tasmota, whose firmware reports power when the hardware has it
|
||||
|
||||
|
||||
def _main_plug_rank(plug: SmartPlug) -> tuple:
|
||||
"""Sort key for choosing the printer's main power plug, best first (#2830).
|
||||
|
||||
A printer's plugs are not interchangeable. The card's Power row carries the
|
||||
power on/off and auto-off-after-print controls, so it has to land on the plug
|
||||
that actually feeds the printer -- pointing those at an exhaust fan is the
|
||||
same harm #2629 fixed for the scheduler's power-on. Ordered:
|
||||
|
||||
1. It can be switched at all -- see ``_can_be_switched``. The row's buttons
|
||||
are the point of it.
|
||||
2. ``controls_printer_power`` -- the flag that says this plug feeds the
|
||||
printer, as opposed to an accessory that merely follows the print cycle.
|
||||
3. ``enabled`` -- a disabled plug ignores automation, so its auto-off toggle
|
||||
would sit there doing nothing.
|
||||
4. ``show_on_printer_card`` -- ranked, not filtered: excluding hidden plugs
|
||||
outright would strip the Power row, and with it the on/off button, from a
|
||||
printer whose only plug has the flag off. It sorts below the power flag
|
||||
because a display preference must not hand power control to an accessory.
|
||||
5. Reports power, so the row shows watts rather than "--" where there is a
|
||||
choice.
|
||||
6. Lowest id, so the answer never depends on row order. The query had no
|
||||
ORDER BY at all, which on Postgres means a plain UPDATE can move a row and
|
||||
silently swap which plug the card calls the printer's power.
|
||||
"""
|
||||
return (
|
||||
not _can_be_switched(plug),
|
||||
not plug.controls_printer_power,
|
||||
not plug.enabled,
|
||||
not plug.show_on_printer_card,
|
||||
not _reports_power(plug),
|
||||
plug.id,
|
||||
)
|
||||
|
||||
|
||||
def _pick_main_plug(plugs: list[SmartPlug]) -> SmartPlug | None:
|
||||
"""The plug the printer card shows as its power, or None if there are none."""
|
||||
return min(plugs, key=_main_plug_rank, default=None)
|
||||
|
||||
|
||||
async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlug]:
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id).order_by(SmartPlug.id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
|
||||
async def get_smart_plug_by_printer(
|
||||
printer_id: int,
|
||||
@@ -146,23 +230,11 @@ async def get_smart_plug_by_printer(
|
||||
):
|
||||
"""Get the main smart plug assigned to a printer.
|
||||
|
||||
When multiple plugs are assigned (e.g., a regular plug + script),
|
||||
returns the main (non-script) plug for power control.
|
||||
When several plugs are assigned -- a printer outlet, an enclosure fan, a
|
||||
script -- returns the one that best fits the card's power controls. See
|
||||
``_main_plug_rank`` for the order and why.
|
||||
"""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plugs = result.scalars().all()
|
||||
|
||||
if not plugs:
|
||||
return None
|
||||
|
||||
# If multiple plugs, prefer the non-script one (main power plug)
|
||||
for plug in plugs:
|
||||
is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.")
|
||||
if not is_script:
|
||||
return plug
|
||||
|
||||
# All are scripts, return the first one
|
||||
return plugs[0]
|
||||
return _pick_main_plug(await _plugs_for_printer(db, printer_id))
|
||||
|
||||
|
||||
@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
|
||||
@@ -176,13 +248,25 @@ async def get_script_plugs_by_printer(
|
||||
Returns HA entities (switches, scripts, lights, etc.) for the printer that have
|
||||
show_on_printer_card enabled.
|
||||
Used to display action buttons alongside the main power plug.
|
||||
|
||||
A switchable main plug is left out: it is rendered directly above this row
|
||||
with its own on/off button, so listing it here draws the same entity twice
|
||||
(#2830). A script is not, because a printer whose only entities are scripts
|
||||
falls back to showing one of them in the power row -- taking it out of this
|
||||
row too would cost the one-click run it has always had there.
|
||||
"""
|
||||
result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id))
|
||||
plugs = result.scalars().all()
|
||||
plugs = await _plugs_for_printer(db, printer_id)
|
||||
main_plug = _pick_main_plug(plugs)
|
||||
duplicate_of_power_row = main_plug.id if main_plug and not _is_script_plug(main_plug) else None
|
||||
|
||||
# Filter to HA entities with show_on_printer_card enabled
|
||||
ha_entities = [
|
||||
plug for plug in plugs if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.show_on_printer_card
|
||||
plug
|
||||
for plug in plugs
|
||||
if plug.plug_type == "homeassistant"
|
||||
and plug.ha_entity_id
|
||||
and plug.show_on_printer_card
|
||||
and plug.id != duplicate_of_power_row
|
||||
]
|
||||
return ha_entities
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""The printer card's Power row and HA row, over the real API (#2830).
|
||||
|
||||
The ranking itself is unit-tested. These drive the two endpoints the card calls,
|
||||
with real rows in the database, because the ranking is only worth anything if
|
||||
the endpoints use it -- and because the second endpoint has to agree with the
|
||||
first about which plug is the main one or the card draws it twice.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
|
||||
|
||||
MAIN = "/api/v1/smart-plugs/by-printer/{}"
|
||||
ENTITIES = "/api/v1/smart-plugs/by-printer/{}/scripts"
|
||||
|
||||
|
||||
async def _ha(smart_plug_factory, printer, entity_id, **kwargs):
|
||||
return await smart_plug_factory(
|
||||
plug_type="homeassistant",
|
||||
ha_entity_id=entity_id,
|
||||
printer_id=printer.id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class TestTheReportedStranding:
|
||||
"""An X1C with two Home Assistant plugs: an exhaust fan with no power
|
||||
monitoring, added first, and the outlet the printer is actually plugged
|
||||
into. The card showed the fan, with "--" where its wattage would be."""
|
||||
|
||||
async def test_the_outlet_takes_the_power_row(self, async_client: AsyncClient, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
await _ha(
|
||||
smart_plug_factory,
|
||||
printer,
|
||||
"switch.print_farm_exhaust_fan",
|
||||
name="Print Farm Exhaust Fan",
|
||||
controls_printer_power=False,
|
||||
show_on_printer_card=False,
|
||||
)
|
||||
await _ha(
|
||||
smart_plug_factory,
|
||||
printer,
|
||||
"switch.bambu_x1c_outlet",
|
||||
name="Bambu X1C Outlet",
|
||||
ha_power_entity="sensor.bambu_x1c_outlet_power",
|
||||
)
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Bambu X1C Outlet"
|
||||
|
||||
async def test_the_outlet_is_not_also_drawn_in_the_ha_row(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
"""It is rendered directly above that row with its own controls."""
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "switch.fan", name="Fan", controls_printer_power=False)
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Bambu X1C Outlet")
|
||||
|
||||
response = await async_client.get(ENTITIES.format(printer.id))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [p["name"] for p in response.json()] == ["Fan"]
|
||||
|
||||
|
||||
class TestTheHAEntityRow:
|
||||
async def test_scripts_and_lights_still_appear(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Outlet")
|
||||
await _ha(smart_plug_factory, printer, "script.start", name="Start Script", controls_printer_power=False)
|
||||
await _ha(smart_plug_factory, printer, "light.chamber", name="Chamber Light", controls_printer_power=False)
|
||||
|
||||
response = await async_client.get(ENTITIES.format(printer.id))
|
||||
|
||||
assert sorted(p["name"] for p in response.json()) == ["Chamber Light", "Start Script"]
|
||||
|
||||
async def test_hidden_entities_stay_hidden(self, async_client: AsyncClient, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Outlet")
|
||||
await _ha(
|
||||
smart_plug_factory,
|
||||
printer,
|
||||
"light.chamber",
|
||||
name="Chamber Light",
|
||||
controls_printer_power=False,
|
||||
show_on_printer_card=False,
|
||||
)
|
||||
|
||||
response = await async_client.get(ENTITIES.format(printer.id))
|
||||
|
||||
assert response.json() == []
|
||||
|
||||
async def test_a_tasmota_main_plug_leaves_the_row_untouched(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
"""Only HA entities are listed there, so excluding the main plug must
|
||||
not remove anything when the main plug was never in the list."""
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="Tasmota Outlet", printer_id=printer.id)
|
||||
await _ha(smart_plug_factory, printer, "light.chamber", name="Chamber Light", controls_printer_power=False)
|
||||
|
||||
response = await async_client.get(ENTITIES.format(printer.id))
|
||||
|
||||
assert [p["name"] for p in response.json()] == ["Chamber Light"]
|
||||
|
||||
|
||||
class TestASinglePlugIsNeverDropped:
|
||||
"""Hidden and disabled are ranking criteria, not filters. Excluding those
|
||||
outright would take the Power row -- and with it the on/off button, and the
|
||||
HA row nested inside it -- off a card that has one plug to show."""
|
||||
|
||||
async def test_a_hidden_plug_still_holds_the_row(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Outlet", show_on_printer_card=False)
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Outlet"
|
||||
|
||||
async def test_an_accessory_still_holds_the_row(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="Filter Fan", printer_id=printer.id, controls_printer_power=False)
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Filter Fan"
|
||||
|
||||
async def test_no_plugs_means_null(self, async_client: AsyncClient, printer_factory):
|
||||
printer = await printer_factory()
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() is None
|
||||
|
||||
|
||||
class TestTheRestOfTheOrder:
|
||||
async def test_an_enabled_plug_beats_a_disabled_one(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(name="Disabled", printer_id=printer.id, enabled=False)
|
||||
await smart_plug_factory(name="Live", printer_id=printer.id)
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Live"
|
||||
|
||||
async def test_a_switch_beats_a_script(self, async_client: AsyncClient, printer_factory, smart_plug_factory):
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "script.start", name="Start Script")
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Outlet")
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Outlet"
|
||||
|
||||
async def test_a_printer_with_only_scripts_still_gets_one(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "script.a", name="First Script")
|
||||
await _ha(smart_plug_factory, printer, "script.b", name="Second Script")
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "First Script"
|
||||
|
||||
async def test_a_script_in_the_power_row_keeps_its_button(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
"""The script-only fallback is unchanged from before #2830, including
|
||||
the one-click run in the HA row. Only a switchable main plug is
|
||||
de-duplicated -- a script reached from the power row costs a confirm
|
||||
dialog it never used to need."""
|
||||
printer = await printer_factory()
|
||||
await _ha(smart_plug_factory, printer, "script.a", name="First Script")
|
||||
await _ha(smart_plug_factory, printer, "script.b", name="Second Script")
|
||||
|
||||
response = await async_client.get(ENTITIES.format(printer.id))
|
||||
|
||||
assert sorted(p["name"] for p in response.json()) == ["First Script", "Second Script"]
|
||||
|
||||
async def test_a_switchable_plug_beats_a_monitor_only_mqtt_plug(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
"""An MQTT plug reports watts but cannot be controlled -- the control
|
||||
endpoint rejects it. Put it in the power row and the on/off button
|
||||
answers with an error."""
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(
|
||||
name="Monitor", plug_type="mqtt", printer_id=printer.id, mqtt_power_topic="tele/printer/SENSOR"
|
||||
)
|
||||
await _ha(smart_plug_factory, printer, "switch.outlet", name="Outlet")
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Outlet"
|
||||
|
||||
async def test_a_lone_monitor_only_plug_still_holds_the_row(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await smart_plug_factory(
|
||||
name="Monitor", plug_type="mqtt", printer_id=printer.id, mqtt_power_topic="tele/printer/SENSOR"
|
||||
)
|
||||
|
||||
response = await async_client.get(MAIN.format(printer.id))
|
||||
|
||||
assert response.json()["name"] == "Monitor"
|
||||
|
||||
|
||||
class TestOtherPrintersAreUnaffected:
|
||||
async def test_plugs_are_not_borrowed_across_printers(
|
||||
self, async_client: AsyncClient, printer_factory, smart_plug_factory
|
||||
):
|
||||
one = await printer_factory()
|
||||
two = await printer_factory()
|
||||
await smart_plug_factory(name="Plug One", printer_id=one.id)
|
||||
await smart_plug_factory(name="Plug Two", printer_id=two.id)
|
||||
await smart_plug_factory(name="Unlinked")
|
||||
|
||||
assert (await async_client.get(MAIN.format(one.id))).json()["name"] == "Plug One"
|
||||
assert (await async_client.get(MAIN.format(two.id))).json()["name"] == "Plug Two"
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Choosing which of a printer's plugs is "the" power plug (#2830).
|
||||
|
||||
The printer card's Power row carries the power on/off and auto-off-after-print
|
||||
controls, so it has to land on the plug that actually feeds the printer. It used
|
||||
to take the first non-script row the database happened to return, which put an
|
||||
exhaust fan -- flagged as not powering the printer, and explicitly hidden from
|
||||
the card -- in front of the outlet the printer is plugged into.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.api.routes.smart_plugs import (
|
||||
_can_be_switched,
|
||||
_is_script_plug,
|
||||
_main_plug_rank,
|
||||
_pick_main_plug,
|
||||
_reports_power,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _plug(plug_id=1, **kwargs):
|
||||
"""A plug with every flag at its model default, overridable per test."""
|
||||
defaults = {
|
||||
"id": plug_id,
|
||||
"name": f"Plug {plug_id}",
|
||||
"plug_type": "tasmota",
|
||||
"ha_entity_id": None,
|
||||
"ha_power_entity": None,
|
||||
"mqtt_topic": None,
|
||||
"mqtt_power_topic": None,
|
||||
"rest_power_path": None,
|
||||
"controls_printer_power": True,
|
||||
"enabled": True,
|
||||
"show_on_printer_card": True,
|
||||
}
|
||||
return SimpleNamespace(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
class TestTheReportedCase:
|
||||
def test_the_printers_outlet_beats_an_exhaust_fan(self):
|
||||
"""The reporter's two Home Assistant plugs on one X1C. The fan was
|
||||
created first, so with no ranking it won on row order alone."""
|
||||
fan = _plug(
|
||||
1,
|
||||
name="Print Farm Exhaust Fan",
|
||||
plug_type="homeassistant",
|
||||
ha_entity_id="switch.print_farm_exhaust_fan",
|
||||
controls_printer_power=False,
|
||||
show_on_printer_card=False,
|
||||
)
|
||||
outlet = _plug(
|
||||
2,
|
||||
name="Bambu X1C Outlet",
|
||||
plug_type="homeassistant",
|
||||
ha_entity_id="switch.bambu_x1c_outlet",
|
||||
ha_power_entity="sensor.bambu_x1c_outlet_power",
|
||||
)
|
||||
|
||||
assert _pick_main_plug([fan, outlet]) is outlet
|
||||
|
||||
def test_and_it_wins_whichever_order_they_arrive_in(self):
|
||||
fan = _plug(1, plug_type="homeassistant", ha_entity_id="switch.fan", controls_printer_power=False)
|
||||
outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
||||
|
||||
assert _pick_main_plug([outlet, fan]) is outlet
|
||||
|
||||
|
||||
class TestTheOrderOfPreference:
|
||||
def test_a_switch_beats_a_script(self):
|
||||
"""A script cannot be switched off, so it can never be the power plug."""
|
||||
script = _plug(1, plug_type="homeassistant", ha_entity_id="script.start_print")
|
||||
switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
||||
|
||||
assert _pick_main_plug([script, switch]) is switch
|
||||
|
||||
def test_a_switchable_plug_beats_a_monitor_only_mqtt_plug(self):
|
||||
"""``control_smart_plug`` rejects MQTT plugs as monitor-only, so the
|
||||
row's on/off button would answer with an error. An MQTT plug is also
|
||||
the kind that reports watts, so it would otherwise win on rank 5 --
|
||||
this is the one pair where the tiebreak could have done real harm."""
|
||||
monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
|
||||
switch = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
||||
|
||||
assert _pick_main_plug([monitor, switch]) is switch
|
||||
|
||||
def test_a_monitor_only_plug_still_holds_the_row_on_its_own(self):
|
||||
"""Rank, not filter: a printer whose only plug is an MQTT monitor keeps
|
||||
the wattage readout it has always had."""
|
||||
monitor = _plug(1, plug_type="mqtt", mqtt_power_topic="tele/printer/SENSOR")
|
||||
|
||||
assert _pick_main_plug([monitor]) is monitor
|
||||
|
||||
def test_a_power_plug_beats_an_accessory(self):
|
||||
accessory = _plug(1, controls_printer_power=False)
|
||||
outlet = _plug(2)
|
||||
|
||||
assert _pick_main_plug([accessory, outlet]) is outlet
|
||||
|
||||
def test_an_enabled_plug_beats_a_disabled_one(self):
|
||||
"""A disabled plug ignores automation -- its auto-off toggle on the card
|
||||
would sit there doing nothing."""
|
||||
disabled = _plug(1, enabled=False)
|
||||
live = _plug(2)
|
||||
|
||||
assert _pick_main_plug([disabled, live]) is live
|
||||
|
||||
def test_a_visible_plug_beats_a_hidden_one(self):
|
||||
hidden = _plug(1, show_on_printer_card=False)
|
||||
visible = _plug(2)
|
||||
|
||||
assert _pick_main_plug([hidden, visible]) is visible
|
||||
|
||||
def test_powering_the_printer_outranks_being_visible(self):
|
||||
"""The two only disagree when someone hides the plug that really feeds
|
||||
the printer. Letting the display flag win would hand the power buttons
|
||||
to an accessory, which is the harm #2629 fixed for the scheduler."""
|
||||
visible_accessory = _plug(1, controls_printer_power=False, show_on_printer_card=True)
|
||||
hidden_outlet = _plug(2, controls_printer_power=True, show_on_printer_card=False)
|
||||
|
||||
assert _pick_main_plug([visible_accessory, hidden_outlet]) is hidden_outlet
|
||||
|
||||
def test_a_plug_that_reports_watts_breaks_a_tie(self):
|
||||
"""Otherwise the row reads "--" while a plug that knows the answer sits
|
||||
one rank below it."""
|
||||
mute = _plug(1, plug_type="homeassistant", ha_entity_id="switch.a")
|
||||
metered = _plug(2, plug_type="homeassistant", ha_entity_id="switch.b", ha_power_entity="sensor.b_power")
|
||||
|
||||
assert _pick_main_plug([mute, metered]) is metered
|
||||
|
||||
def test_reporting_watts_does_not_outrank_powering_the_printer(self):
|
||||
metered_accessory = _plug(1, ha_power_entity="sensor.fan_power", controls_printer_power=False)
|
||||
mute_outlet = _plug(2, plug_type="homeassistant", ha_entity_id="switch.outlet")
|
||||
|
||||
assert _pick_main_plug([metered_accessory, mute_outlet]) is mute_outlet
|
||||
|
||||
|
||||
class TestDeterminism:
|
||||
def test_equal_plugs_resolve_by_id(self):
|
||||
"""The query had no ORDER BY, so on Postgres an unrelated UPDATE could
|
||||
move a row and silently swap which plug the card called the printer's
|
||||
power."""
|
||||
first = _plug(1)
|
||||
second = _plug(2)
|
||||
|
||||
assert _pick_main_plug([second, first]) is first
|
||||
|
||||
def test_the_documented_order_holds_end_to_end(self):
|
||||
"""One plug per criterion, each failing only that one, ranked together.
|
||||
Pairwise tests would pass against an order with the middle two swapped."""
|
||||
ideal = _plug(6)
|
||||
mute = _plug(5, plug_type="homeassistant", ha_entity_id="switch.mute")
|
||||
hidden = _plug(4, show_on_printer_card=False)
|
||||
disabled = _plug(3, enabled=False)
|
||||
accessory = _plug(2, controls_printer_power=False)
|
||||
script = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
|
||||
|
||||
ranked = sorted([script, accessory, disabled, hidden, mute, ideal], key=_main_plug_rank)
|
||||
|
||||
assert ranked == [ideal, mute, hidden, disabled, accessory, script]
|
||||
|
||||
|
||||
class TestNoPlugs:
|
||||
def test_no_plugs_means_no_main_plug(self):
|
||||
assert _pick_main_plug([]) is None
|
||||
|
||||
def test_all_scripts_still_yields_one(self):
|
||||
"""Pre-existing behaviour: a printer whose only entities are scripts
|
||||
still gets a Power row, because the card nests everything else inside
|
||||
it. Ranking must not turn that into an empty card."""
|
||||
second = _plug(2, plug_type="homeassistant", ha_entity_id="script.b")
|
||||
first = _plug(1, plug_type="homeassistant", ha_entity_id="script.a")
|
||||
|
||||
assert _pick_main_plug([second, first]) is first
|
||||
|
||||
|
||||
class TestSwitchability:
|
||||
@pytest.mark.parametrize(
|
||||
"plug,expected",
|
||||
[
|
||||
(_plug(plug_type="tasmota"), True),
|
||||
(_plug(plug_type="rest"), True),
|
||||
(_plug(plug_type="homeassistant", ha_entity_id="switch.outlet"), True),
|
||||
(_plug(plug_type="homeassistant", ha_entity_id="light.chamber"), True),
|
||||
(_plug(plug_type="homeassistant", ha_entity_id="script.start"), False),
|
||||
(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug"), False),
|
||||
],
|
||||
)
|
||||
def test_matches_what_the_control_endpoint_accepts(self, plug, expected):
|
||||
assert _can_be_switched(plug) is expected
|
||||
|
||||
|
||||
class TestUnsetFlags:
|
||||
"""An upgraded database adds these columns by ALTER, so they are nullable
|
||||
there even though a fresh one declares them NOT NULL. Every row is
|
||||
backfilled with the default and nothing writes a null, but a rank that
|
||||
raised on one would take down the whole printers page."""
|
||||
|
||||
def test_a_plug_with_null_flags_ranks_last_rather_than_raising(self):
|
||||
unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
|
||||
ordinary = _plug(2)
|
||||
|
||||
assert _pick_main_plug([unset, ordinary]) is ordinary
|
||||
|
||||
def test_and_still_holds_the_row_when_it_is_the_only_plug(self):
|
||||
unset = _plug(1, controls_printer_power=None, enabled=None, show_on_printer_card=None)
|
||||
|
||||
assert _pick_main_plug([unset]) is unset
|
||||
|
||||
def test_a_plug_with_no_type_at_all_does_not_raise(self):
|
||||
assert _pick_main_plug([_plug(1, plug_type=None)]) is not None
|
||||
|
||||
|
||||
class TestScriptDetection:
|
||||
@pytest.mark.parametrize(
|
||||
"entity_id,expected",
|
||||
[
|
||||
("script.start_print", True),
|
||||
("switch.outlet", False),
|
||||
("light.chamber", False),
|
||||
(None, False),
|
||||
],
|
||||
)
|
||||
def test_only_ha_script_entities_count(self, entity_id, expected):
|
||||
assert _is_script_plug(_plug(plug_type="homeassistant", ha_entity_id=entity_id)) is expected
|
||||
|
||||
def test_a_tasmota_plug_is_never_a_script(self):
|
||||
assert _is_script_plug(_plug(plug_type="tasmota", ha_entity_id="script.confusing")) is False
|
||||
|
||||
|
||||
class TestPowerCapability:
|
||||
"""Read from configuration, not measured -- this runs on every card render.
|
||||
|
||||
It is a floor: an HA plug with no dedicated sensor may still report watts
|
||||
from the switch entity's own ``current_power_w`` attribute, which only a
|
||||
live read would show. Good enough for a tiebreak, and it never decides
|
||||
anything on its own.
|
||||
"""
|
||||
|
||||
def test_tasmota_reports_power_natively(self):
|
||||
assert _reports_power(_plug(plug_type="tasmota")) is True
|
||||
|
||||
def test_home_assistant_needs_a_power_sensor(self):
|
||||
assert _reports_power(_plug(plug_type="homeassistant", ha_entity_id="switch.a")) is False
|
||||
assert _reports_power(_plug(plug_type="homeassistant", ha_power_entity="sensor.a_power")) is True
|
||||
|
||||
def test_mqtt_accepts_either_the_new_or_the_legacy_topic(self):
|
||||
assert _reports_power(_plug(plug_type="mqtt")) is False
|
||||
assert _reports_power(_plug(plug_type="mqtt", mqtt_power_topic="tele/plug/SENSOR")) is True
|
||||
assert _reports_power(_plug(plug_type="mqtt", mqtt_topic="zigbee2mqtt/plug")) is True
|
||||
|
||||
def test_rest_needs_a_power_path(self):
|
||||
"""The REST service returns no energy at all without one, so a URL on
|
||||
its own proves nothing."""
|
||||
assert _reports_power(_plug(plug_type="rest")) is False
|
||||
assert _reports_power(_plug(plug_type="rest", rest_power_path="apower")) is True
|
||||
Reference in New Issue
Block a user