Merge branch 'dev' into feature/2656-restore-from-github

This commit is contained in:
jmoore-skild
2026-08-05 16:47:49 -04:00
59 changed files with 6050 additions and 196 deletions
+8 -3
View File
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
## [1.2.6b1] - Unreleased
### Added
- **Home Assistant sensors on the printer card, with an optional print interlock (#1148, reporter @bsaunder; #448, reporter @baudneo)** — Bambuddy could already switch a Home Assistant entity as a smart plug, but it had no way to *read* one. A printer in a home-built enclosure with a door contact, or an A1 with an aftermarket chamber thermometer, had all that data in Home Assistant and none of it in Bambuddy — the reporter's actual problem being that he could not tell whether he had left the enclosure open before starting a print from his phone. **Settings → Smart Plugs → Home Assistant Sensors** now binds any `binary_sensor` — a door, window, smoke or moisture contact — or any `sensor` that carries a reading to a printer, and its state appears on that printer's card. The wording follows Home Assistant's own device class, so a door reads Open or Closed rather than On or Off, and a thermometer reads `41.2 °C`; entities with no device class fall back to on/off, exactly as Home Assistant shows them. A sensor can be given an alert condition — on, off, above a value, below a value — which highlights it on the card and unlocks two things it would otherwise be pointless to offer. **Notify on alert** sends a notification the moment the sensor enters that state, once on the way in rather than on every poll, and not again when a flaky contact drops off the network and comes back still alerting. **Hold prints while alerting** is the part that answers the original question without anyone having to look: queued jobs for that printer wait, with a reason on the Queue page you can read at a glance ("Waiting on Enclosure Door"), and start by themselves as soon as the door shuts. Nothing is ever cancelled, and a job queued as "Any X1C" simply goes to a sibling whose sensors are clear instead of waiting behind the one that is held. The interlock is deliberately one-directional: it holds only on a sensor that was read successfully and *is* alerting, so a Home Assistant that is unreachable holds nothing and the queue keeps running as though no interlock were configured. Sensors are their own thing rather than a smart plug with a wider entity filter — a plug carries auto-on, schedules, power alerts and "controls printer power", and the printer card's power button would have happily tried to switch a door contact. One backend poller reads every bound entity every 15 seconds and the cards serve that cached reading, so the cost to Home Assistant does not grow with the number of printers on screen or browser tabs open. Off by default in every respect: a newly bound sensor is display-only until you give it an alert condition, and both the notification and the interlock are separate opt-ins on top of that.
- **Open a File Manager model in your desktop slicer, and pick which one from the 3D preview (#2725, contributor @pascalheidmann)** — The **Slice** action on a file card only existed when the optional slicer sidecar was running. Turn the sidecar off — which is the default, and how most installs run — and the File Manager offered no way to get a model into a slicer at all, even though the Archives page has handed files to a locally-installed Bambu Studio or OrcaSlicer over the URI scheme for a long time. The File Manager was simply the one place that never got it. **Slice** now appears on every unsliced model (`.3mf`, `.stl`, `.step`, `.stp`) in both the card menu and the list view, and does whichever of the two things your configuration means: with the sidecar on it opens Bambuddy's slice modal and the work happens on the server, and with it off it hands the file to your desktop slicer. The icon says which you will get before you click — a cog for server-side slicing, an external-link arrow for the handoff — and which slicer receives the handoff comes from **Settings → Workflow → Slicer → Open in Slicer**, falling back to the preferred slicer as it always has. The 3D preview goes further, because that is where you are actually looking at the model and deciding: its slicer button is now a split button, and the chevron beside it offers the alternatives without changing any setting. With the sidecar off that is the slicer you did *not* pick as your desktop target; with it on, the primary button still slices server-side and the menu offers a one-off desktop handoff to either slicer. Two things that used to be silent now are not: a handoff refused for want of permission raises an error toast rather than launching the slicer at a URL it cannot fetch, where a permission problem looked exactly like "no slicer installed"; and the file-type rule is shared between the card menu and the 3D preview, so a file can no longer offer **Slice** in one place while showing it greyed out in the other. Permissions follow the endpoint each mode calls — the handoff is a download and needs the same library read permission that lets you see the file, server-side slicing writes a new file and needs upload rights — and the action is shown disabled with the missing permission named rather than hidden. Translated in all locales; wiki updated. Covered by frontend tests.
- **Temperatures on the streaming overlay, and a builder for its URL (#1422, reporter @SMAW)** — The overlay at `/overlay/{printer}` draws live print data over a full-screen camera view for OBS, a wall display or any browser source. It could already be tuned — which fields, what size, what frame rate — but only through query parameters documented in the wiki, and temperatures were not among the fields on offer. Both are now addressed. Nozzle, bed and chamber readings join the list, shown with the target while the heater is still climbing and with the target dropped once it is reached, so a settled hotend reads "220°C" rather than "220 / 220°C" for the rest of the print. Both nozzles appear on a dual-nozzle printer. They are drawn whether or not a print is running, since a preheating machine is exactly when they are worth watching, and each reading appears only when the printer genuinely reports it — chamber temperature stays absent on P1 and A1 models, which publish a value with no sensor behind it. And **Settings → API Keys → Streaming Overlay** now builds the URL for you: pick the printer, tick the fields, set size and frame rate, paste in a token if login is enabled, and copy the result, with an optional preview alongside it. The preview stays off until you ask for it so that leaving the settings page open does not hold a viewer on the printer's single camera connection. Making that preview possible needed one narrow change to the security headers: the overlay path now sends `frame-ancestors 'self'` instead of `'none'`, so Bambuddy's own UI can embed it. Every other page still refuses to be framed at all, `'self'` permits a framer only on this same origin, and embedding the overlay from another host — Home Assistant on a different port, say — is unchanged and still requires `TRUSTED_FRAME_ORIGINS`. Temperatures are not in the default field set, so an overlay URL already pasted into a scene looks exactly the same after upgrading. Translated in all locales, wiki updated, covered by backend and frontend tests.
- **The external spool can be hidden from the printer card (#1782, reporter @Arn0uDz)** — An external spool holder that never gets used still occupies a full card's width in the **Filaments** row, next to the AMS units that are actually being used. An eye icon at the right-hand end of that row's header now hides it, and clicking it again brings it back, so nothing is lost behind a settings page you would have to remember. The choice is remembered per printer and stored in the browser, like the card size and the offline-printer filter — one machine in a fleet can be tidied up without touching the others, and nothing changes for anyone else using the same Bambuddy. The icon is deliberately absent on a printer with no AMS: there the external spool is the entire filament section, and hiding it would leave an empty row. That guard also covers the case of an AMS being unplugged from a printer whose external spool was hidden earlier — the spool reappears rather than leaving a blank row behind. On the H2D and H2S both external positions share one card and so hide together. Translated in all locales, wiki updated, covered by frontend tests.
@@ -14,13 +16,16 @@ 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
- **Configuring an AMS slot shows up on the printer card straight away, without a page reload** — Setting a slot to a different filament from the printer card left the card showing the old one. Nothing was lost: the command reached the printer, the printer applied it, and reloading the page or waiting out the thirty-second fallback poll showed the new filament. It simply never arrived on its own. Bambuddy compares each status push from a printer against the last one it broadcast and stays quiet when nothing has changed, which is what keeps a machine mid-print from flooding every open browser tab several times a second. The comparison looked at each AMS tray's slot number, material and load state — and **Configure Slot** writes none of those. It writes the filament id, the colour, the profile name and the calibration profile. So changing PLA to a different brand or colour of PLA produced a push that looked identical to its predecessor and was discarded, while changing PLA to PETG came through immediately because the material had moved. That is also why **Reset** always worked: it clears the material. The comparison now covers the filament identity as well, so every kind of slot change reaches the card. Those fields only move when someone configures a slot or swaps a spool, so this adds no traffic during a print — the amount of filament left, which does tick down continuously, is still deliberately excluded. Covered by backend tests.
- **"Any X2D" works on a printer that feeds from external spools instead of an AMS (#2771, reporter @Nick-C130)** — A fleet of five X2Ds with no AMS units, each printing PETG from its external spool holder, accepted a job sent to a named printer and refused the same job sent to **Any X2D**: the file uploaded, the printer answered "Failed to get AMS mapping table", and after three attempts the queue item failed. The two paths differ in one thing. A job queued for a named printer carries a filament mapping the browser worked out at the time you queued it; a job queued for a model has no printer yet, so the scheduler has to work the mapping out at dispatch — and its copy of that logic could not see an external spool on a dual-nozzle printer. On an X2D or H2D each filament in the sliced file names the nozzle it feeds, and Bambuddy will only offer a spool to the nozzle it is physically plumbed to. Which nozzle an external spool feeds was being read off a table the printer builds from its AMS units, so a printer with no AMS published an empty table, every external spool came back belonging to no nozzle at all, and the per-nozzle check discarded the only filament on the machine. Nothing matched, and the print went out claiming to use an AMS while carrying no mapping — which is the message the firmware was objecting to. The left and right external feeds identify themselves well enough to be routed without that table, and the printer reports its two nozzles directly, so both are now used. This is the same fault that was corrected in the browser last May for exactly this hardware; the scheduler kept the old logic, which is why the browser-resolved mapping worked and the scheduler-resolved one did not. Single-nozzle printers are untouched — they have no nozzle to route to and never took this branch. Separately, a job whose filament genuinely cannot be matched on a printer with no AMS now fails immediately and says which filament is missing and which nozzle wants it, instead of uploading several megabytes, collecting the firmware's error and failing anyway two retries later; where there *is* an AMS the firmware error still stands, because there the job can be recovered by loading a spool and pressing **Resume**. Covered by backend tests.
- **LDAP login works again on directories that define no POSIX group class (#2769, reporter @peterskotte)** — Every LDAP user on an lldap directory was rejected with "Incorrect username or password", including users whose credentials, search filter and group membership all checked out when tested by hand with `ldapsearch`, and on an install where **Test Connection** reported success. The password was never the problem and the directory never saw the request. When resolving a user's groups Bambuddy looks for POSIX groups alongside the usual `memberOf` ones, and both of those searches name the `posixGroup` object class. The LDAP client validates class names in a filter against the schema the server publishes, and rejects an unknown one while building the request, before anything is sent. lldap marks every account it creates as `posixAccount`, which is what makes Bambuddy look for POSIX groups in the first place, but defines no group class beyond `groupOfNames` — so the search was refused, the refusal travelled all the way out of the login routine, and the login route reports any LDAP failure as bad credentials. A directory with no `posixGroup` class has no `posixGroup` entries, which is precisely the answer those searches would have returned, so Bambuddy now treats the refusal as the empty result it stands for, notes it once in the log and carries on with the `memberOf` groups. The reporter's mapped group is one of those, so it resolves as configured. This is not a regression from the recent primary-group work, though that is the natural suspect: the `memberUid` search has named the same class since LDAP support first shipped, and it runs for every user whether or not they have a `gidNumber`, so login has never worked against a directory of this shape. **Test Connection** passed throughout because it asks only whether any entry exists, a form of filter that carries no class name to validate. Nothing changes for Active Directory or for an OpenLDAP that loads the standard NIS schema — both define the class, and their POSIX groups are still read. Wiki updated. Covered by backend tests.
- **Spoolman no longer charges a Bambu Studio print to the wrong spool (#2768)** — A sliced file numbers its filaments 1, 2, 3, 4, and which AMS tray each of those came from is a separate decision made when the job is sent. Bambuddy learns that decision one of two ways: it made the choice itself, for a print started from Bambuddy, or it read the print command as it crossed the local network, for a print sent from a slicer. A job dispatched from Bambu Studio while the printer is signed in to Bambu's cloud satisfies neither — the command travels through Bambu's own broker and never appears on the network Bambuddy is listening to. With nothing recorded, the Spoolman writer fell back to assuming the AMS was loaded in slicer order: filament 1 from the first loaded tray, filament 2 from the second. The reporter's X1C was loaded in the order 2, 4, 1, AMS-HT, so every one of the four was deducted from the wrong spool. It also changed what the print looked like afterwards: on completion Bambuddy stamps the archive with the material and colour of the spools it charged, so the print showed the right filament while it ran and switched to a different one the moment it finished — which is how the reporter noticed. The printer knew the answer all along. It publishes the running job's slot-to-tray assignment in its own status, and Bambuddy's built-in filament inventory has read that field for as long as it has resolved mappings at completion; only the Spoolman writer, which resolves at print start instead, never learned to. It now consults the same two fallbacks at the same moment: the printer's report first, and failing that a colour match of the sliced filaments against the loaded trays, which covers the A1, A1 Mini, P1S and P2S — those models publish no such field, so their owners were on the positional guess no matter how the print was sent. Reading the field at completion rather than at print start is deliberate: a printer keeps publishing the last job's mapping while it sits idle, so consulting it early risks stamping the previous print's mapping onto this one. A mapping Bambuddy or the slicer actually recorded is never second-guessed, so nothing changes for prints started from Bambuddy, from the queue, or over LAN. Cancelled and failed prints take the same correction, since partial usage is charged through the same mapping. The resolved mapping and where it came from are now logged at both print start and completion, so the next report of a wrong deduction can be read straight out of a support bundle. Wiki updated. Covered by backend tests.
- **A drying cycle the printer abandons now says so, and says what the printer reported (#2770, reporter @tchavei)** — An H2D started a twelve-hour PETG dry at 65°C and the AMS gave up on it twenty minutes in, with 700 of the 720 minutes still on the clock. It then cooled off, humidity climbed back over the threshold, auto-drying started another twelve-hour cycle, and that one was abandoned the same way — a loop the reporter's AMS temperature history shows running all morning. The log had one line to say for it: `AMS 0 drying complete`, which is exactly what it says for a dry that ran its full twelve hours. Nothing in a support bundle told the two apart, and the remaining time — the one number that does — was written into the line as the *previous* value, where it reads like a duration rather than a shortfall. Bambuddy did not stop that cycle. Every stop it sends is logged with the full command as it goes out, and there was none, so ending it was the printer's decision — and the only account of why lives in three things Bambuddy already receives and parses but has never written down: the drying phase and sub-phase the AMS reports in its status word, the firmware's own cannot-dry reason codes (which distinguish an overheating unit from one being starved of power by a missing external supply), and whatever HMS errors are live at that moment. A cycle that ends with most of its countdown left now logs all three, alongside how much of the requested duration actually ran and how much was asked for. A cycle that reaches its configured duration keeps the single line it has always had, so a normal dry does not start reporting diagnostics nobody needs, and a cycle Bambuddy itself ends — the print-takes-priority stop, or the **Stop** button — now says so by name rather than being reported as an unexplained early end, since a stop is short of its duration too and looks identical in the telemetry. This is diagnostics only: nothing about when drying starts or stops has changed, and the repeated restart itself is not addressed here — what the firmware objects to has to be established before Bambuddy can sensibly decide how long to wait before trying again. Covered by backend tests.
- **A drying cycle no longer reports itself finished a minute after it starts (#2759)** — Starting the dryer on an AMS 2 Pro holding two PETG and two PLA spools and picking PLA showed "PLA @ 45°C" for about a minute, then switched to "PETG @ 65°C" for the remaining twelve hours. Bambu never echoes back which filament or temperature a cycle is running, so the badge reads the target Bambuddy cached when it sent the command — and that cache had been thrown away. Between accepting the command and settling its countdown the firmware publishes one update with the remaining time at zero while the unit is still in its Checking phase; the reporter's log caught 720 minutes, then 0, then 719. Bambuddy read the zero as the cycle ending. Losing the cached target left the badge to guess the filament from the first loaded slot, which happened to be PETG, and its RFID-recommended 65°C — a confident wrong answer for a cycle running PLA at 45. The same false ending also armed smart-plug auto-off-after-drying, so anyone with that switched on had power scheduled to cut one minute into a twelve-hour dry. A remaining time of zero is now only treated as the end of a cycle when the AMS also reports an idle phase, which the firmware already publishes alongside it; stopping a dry early still ends it immediately, and a unit that reports no phase at all still ends its cycles as before. The fallback guess has been tightened to match, in both directions. It names a filament only when every loaded spool agrees on one — on a mixed unit the badge shows the countdown alone rather than naming a spool the cycle isn't drying — and it no longer guesses a temperature at all. A unit loaded entirely with PLA does tell you what is being dried, but not at what temperature: that is picked freely when the cycle is started, so the spools' RFID-recommended value is never evidence of it, and a second AMS loaded only with PLA and drying at 45°C still read "PLA @ 55°C" whenever the cached target went missing. The badge now names a temperature only when Bambuddy sent it, and shows the filament and countdown without one otherwise. Covered by backend and frontend tests.
- **A print that never starts now says AMS drying was running, instead of blaming the SD card (#2758)** — Sending a job to an X2D with two AMS units mid-drying failed silently: the file uploaded, the printer accepted it and then simply stayed idle. Bambuddy waited out the start watchdog, re-uploaded the whole 3MF, waited again, and after three attempts gave up with advice to check the printer's screen and the SD card — while Bambu Studio, asked directly, said it could not start the job because of the drying. Bambuddy now watches the AMS drying telemetry it already receives across the dispatch window and, when a job never starts while a unit was drying, names the units in the failure message and records the correlation in the log from the first attempt rather than only after the retries are spent. This is deliberately a diagnosis and not a rule: the printers concerned support drying *continuing* through a print, so drying and printing are not in conflict as such, and the report also involved one AMS drying without its external power supply — which would make the start-of-print calibration a power problem rather than a drying one. Stopping the cycle automatically would therefore be acting on a guess, and could tear down drying the hardware was happy to continue. Until it is known which of the two is the real obstacle, Bambuddy tells you what it saw and leaves the call to you. The message for a stalled dispatch with no drying involved is unchanged. Wiki updated. Covered by backend tests.
- **A hand-written systemd service left the Virtual Printer unable to start, with nothing obvious to blame (#2549, reporter @Ru3ck3)** — The Virtual Printer binds ports 990 and 322, both below 1024, which a service running as a normal user may not do without the `CAP_NET_BIND_SERVICE` capability. Without it the rest of Bambuddy works perfectly and only the Virtual Printer is dead: its sockets never open, the slicer never finds the printer, and the sole trace is one line in the journal. The reporter lost days to this before someone on Discord spotted the missing line. The install script has carried it since March, but the three other places that define the same service did not — the manual-install template, the combined Bambuddy plus SpoolBuddy installer, and the unit the wiki tells you to paste. All three have it now, and the wiki no longer claims the capability is always included when its own instructions omitted it. Bambuddy also diagnoses this itself: **Diagnose** on the virtual printer card previously reported only that nothing was listening on port 990, which reads identically to an ordinary port conflict. It now checks whether the process actually holds the capability and, when that is what is wrong, says so and gives the line to add. The check stays quiet when the port is answering, since fronting it another way (an iptables redirect is the documented alternative) is a legitimate setup, and it stays quiet when the capability is held, so a port that failed for some other reason is not misattributed. Existing installs are unaffected until reinstalled; the diagnostic tells you whether yours needs the line. Translated in all locales; wiki updated. Covered by backend tests.
- **A refused AMS filament setting now says so in the log (#2756, reporter @Jostxxl)** — Configuring a slot publishes an `ams_filament_setting` command, and the printer answers it with a verdict. That answer was received and then thrown away at debug level, so a printer that refused the write left no trace at the log level support bundles are collected at. The reporter hit exactly that: six manual **Configure Slot** attempts on one X1C, every one returning success, every one read back by the #2582 verification as still holding the previous profile, and nothing anywhere to say what the printer had made of the command. A refusal is now logged with the printer's own `result` and `reason` alongside the AMS and tray it concerned. Only refusals are promoted — unlike the K-profile and drying commands this one is not rare, since every spool assignment and every K-profile re-apply sends one, and logging each acknowledgement would bury the line worth reading. The developer-mode probe is excluded as well: it sends this same command to the external slot specifically to watch it be refused on P1 firmware, so its failure is a measurement rather than a fault. Diagnostics only — nothing about which commands are sent or how they are built has changed. Covered by backend tests.
- **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched to another tab and jumped straight to the current value the moment you switched back, which defeats the point of putting it in the title. The cause was not in the tab-title feature: every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all. Those callbacks are not slowed down, they are held — so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue that carries every non-status message, which stalled completely and accumulated messages until the tab came back. The animation frames were added alongside the real fix for a browser freeze on print completion — that fix was the batching, which is untouched; the frames only ever deferred each write by about a sixteenth of a second and are gone. One limit is worth knowing about and is the browser's rather than ours: browsers deliberately slow down timers in tabs you are not looking at, to roughly once a second, and to about once a minute once a tab has been hidden for five minutes. So the title keeps moving in the background, but on a tab left alone for a long time it steps rather than ticks. Covered by frontend tests that reproduce a hidden tab.
- **Live updates stopped arriving while the Bambuddy tab was in the background (#2754, reporter @mic4rd)** — The progress percentage in the tab title froze whenever you switched away and jumped straight to the current value the moment you came back, which defeats the point of putting it in the title. There were two causes, and the first fix only got one of them. Every printer status arriving over the WebSocket was written into the browser's cache from inside an animation-frame callback, and a browser gives a hidden tab no frames at all — those callbacks are not slowed down, they are held, so the connection stayed up, the messages kept arriving, and every one of them parked in a queue that only ran when the tab was shown again. The same applied to the archive, inventory and spool refreshes, and to the queue carrying every non-status message, which stalled completely. Removing the frames fixed that stall but not the report, because the write still went through a 100 ms timer that batches rapid updates — and a timer is exactly what a browser throttles in a tab you are not looking at, to roughly once a second, and to about once a minute once the tab has been hidden for five minutes. The reporter's screenshot showed a tab title reading 2% beside a page at 40%. That batching exists to stop a burst of messages causing a rendering pile-up, and a hidden tab is not rendering, so there is nothing to protect there: while the tab is hidden the value is now written straight through, and the batching still applies while you are looking at it. Worth knowing if you use Windows: a browser window completely covered by another window counts as hidden, not merely unfocused, which is why this could bite without ever switching tabs. Covered by frontend tests that reproduce a hidden tab, including one that never advances the clock — the earlier tests passed by simulating the very timer the browser was throttling.
- **The bug-report button no longer covers the controls in the bottom-right corner (#2750, reporter @goodjaltman)** — On a phone the floating red button sits on top of whatever else is in that corner, which turns out to be most things: the scroll-to-top button on Profiles was ~83% underneath it and, since both sit at the same stacking level, which one you could actually tap came down to the order they happened to render in. The floating camera window parks there, as do the Group Edit save bar, the bulk-selection toolbars, and — because the button is pinned to the viewport rather than the page — the per-card action buttons on File Manager and Archives simply scroll underneath it. The reporter asked for a switch to hide the button, but it is the only way into the report form, and that form is not just a text box: it runs the printer connection diagnostic, scans your logs against the known-issue catalog, optionally captures five minutes of debug logging and attaches a support bundle. Hiding it doesn't produce smaller reports, it produces reports with nothing attached. So the button moves instead of disappearing. Once the window is narrow enough that the sidebar collapses into a menu button, the bug icon moves into that top bar and the corner is left alone; above that width nothing changes. That threshold is the one the layout already switches on, so there is no new breakpoint and no third state to reason about, and it covers tablets and half-width desktop windows rather than only phones. The report form itself is now a proper bottom sheet on phones, which also fixes it hanging 16 pixels off the left edge of the screen — it was sized to the full viewport width and then inset from the right, so a strip of the form was simply unreachable on anything under about 460 pixels wide. The scroll-to-top button on Profiles has been nudged clear of the corner as well, for the wide layouts where the floating button stays. Wiki updated. Covered by frontend tests.
- **The Print Log's cost and energy figures were never sent to the browser** — Bambuddy has been recording what each run cost and how much power it drew, but the two Print Log endpoints built their responses field by field and never mentioned `cost`, `energy_kwh` or `energy_cost`. A field nobody names comes back as its default, so the values arrived as nulls — indistinguishable from a column that genuinely holds nothing, with no error and no log line to say otherwise. The same trap had already swallowed the failure-cause classification once before. Both endpoints now validate straight off the database row, which removes the opportunity to forget a field rather than fixing the three that happened to be missing. Existing rows need no migration: the data was always there. Covered by backend tests.
- **The Print Log is reachable again once you have no archives** — The Archives page decided it had nothing to show before it checked which view you were on, so with zero archives the "No archives yet" card replaced every view including the log. The Print Log is a separate table that deliberately outlives the archives it refers to — deleting an archive only clears the reference, and clearing the log is its own action — so purging archives hid a history that was still in the database, with no way back to it short of re-adding an archive. The log view now renders its own empty state instead of borrowing the archive one. Wiki updated. Covered by a frontend test.
@@ -50,7 +55,7 @@ All notable changes to Bambuddy will be documented in this file.
- **Users can now delete empty folders in the File Manager (#1781, reporter @cadtoolbox)** — Library folders have no ownership tracking, so folder deletion was gated entirely behind `library:delete_all` — a regular user with `library:delete_own` could create folders and delete their own files, but the emptied folder sat there until an admin removed it. Users with `library:delete_own` can now delete folders that are truly empty: no subfolders, no files — including trashed ones, since deleting a folder would silently drop another user's trash-restorable files. External folders (operator-configured mounts) and folders linked to a project or archive still require `library:delete_all`, even when empty. The folder tree's Delete entry enables accordingly, with a "You can only delete empty folders" tooltip on non-empty ones; the bulk-delete API applies the same rule. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **AI failure detection is now visible on the printer cards (#1546, reporter @Jeff-GebhartCA)** — Previously the live Obico classification (safe / warning / failure, smoothed score) was only visible under Settings → Failure Detection, so tracking how detection matched an ongoing print meant flipping between the Printers screen and Settings. Each printer card's badge row now shows an AI badge whenever detection is enabled for that printer, like the other health badges: gray **Idle** while no print is being watched, then green **Safe**, amber **Warning**, or red **Failure** while a print is actively monitored. The tooltip carries the current score, and clicking opens a modal (like the HMS error badge) with the live status, score, frames analyzed, and the detection service's last error — plus a shortcut to the full settings. Toggling detection on or off updates the cards immediately. Printers excluded from monitoring and setups without failure detection show nothing. Served by a new lightweight `/obico/printer-status` endpoint readable with printer permissions alone (the existing settings-gated endpoint is unchanged and keeps configuration private). Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Bark is now a notification provider (#1495)** — [Bark](https://github.com/Finb/Bark) is the open-source, account-free iOS push app (self-hostable via bark-server), popular especially with Chinese-speaking users. Configure it with just the device key from the app; the server URL defaults to the official `api.day.app` relay and accepts a self-hosted instance. Optional settings: notification **Group**, **Sound**, and iOS **Interruption Level** — Time Sensitive breaks through scheduled summaries, Critical bypasses Silent mode and Focus (useful for print-failure alerts), Passive delivers silently. Send failures wrapped in an HTTP 200 body by bark-server are detected and reported properly. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`) and nested options work. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Home Assistant notifications can carry custom data fields (#1441)** — When a notification provider targets an HA notify service (e.g. `notify.mobile_app_myphone`), a new optional **Data (JSON)** field is forwarded as the service call's nested `data` object — the same place HA automations put mobile push options like `priority`, `ttl`, `channel`, and `group`. `ttl: 0` + `priority: high` make Android pushes arrive immediately instead of batched, and `channel` gives printer alerts their own notification channel/sound. The field is JSON (not key=value lines) so numbers stay numbers (`ttl: 0`). It is passed to Home Assistant exactly as written, so anything the notify service accepts belongs there — including nested objects and lists, not only the flat values above. **Action buttons** are the case worth naming: an `actions` list of `{"action": ..., "title": ...}` objects reaches the Companion app the same way an HA automation sends it. Two things decide whether those buttons do anything, and neither is set in Bambuddy: pressing one fires a `mobile_app_notification_action` event that an HA automation has to be listening for, and iOS ignores a bare `actions` list entirely — there, buttons come from a notification `category` registered in the Companion app. Validated on both ends: the UI rejects malformed JSON before saving, and the sender fails loudly with a clear message rather than posting a half-built payload. Only included when configured — the default persistent-notification path is unchanged, as its schema rejects unknown keys. Translated in all locales; wiki updated. Covered by backend and frontend tests.
- **Energy usage now feeds the statistics that previously only knew about filament (#1432)** — Bambuddy has measured per-print energy via an attached smart plug for a while (the plug's lifetime counter is captured at print start and the delta stored with the print), but two stats surfaces ignored it. First, the **Most Expensive** record on the Statistics page ranked prints by filament cost alone, so a cheap-filament print with hours of heated-chamber time could never win; it now ranks by filament + measured energy cost (prints without a smart plug simply compete on filament cost, as before). Second, **Filament Trends** gained an **Energy Over Time** chart — per-day kWh (per-hour for short ranges, per-week for long ones), with the range's total kWh and energy cost in the header. The chart only appears when the selected range actually contains measured energy data, so setups without smart plugs see no change. The `/archives/slim` stats feed now carries each run's `energy_kwh`/`energy_cost`. Translated in all locales. Covered by backend and frontend tests.
- **The plate-clear gate is now visible over MQTT, and can raise a notification (#2525, reporter @daschaefer)** — When a print reaches a terminal state, Bambuddy holds the queue until someone confirms the build plate is clear. That gate was visible only in the Web UI: the printer's own MQTT push reports nothing beyond `RUNNING`/`PAUSE`/`FAILED`/`FINISH`/`IDLE`, so an external automation could not tell "finished" from "finished and still waiting for a human". The per-printer status topic now carries an **`awaiting_plate_clear`** field, and every transition is additionally published on a new **retained** topic `bambuddy/printers/{serial}/plate_clear` (`{"awaiting": true|false, …}`). Retained and published from the flag itself rather than from printer telemetry, so a subscriber learns the current state of every printer the moment it connects — and the state stays correct after Auto Off powers a printer down, which stops telemetry entirely and would otherwise leave the status topic frozen at `false`. Publishing is edge-triggered: the queue re-asserts the flag on every dispatch, and no subscriber should see a "plate cleared" for a plate that was never dirty. A matching **Plate Clear Required** notification event was added, off by default on every provider because it fires after every print at the same moment as the print-complete alert. Acknowledging still goes through the existing `POST /printers/{id}/clear-plate`. Translated in all locales; wiki updated. Covered by backend tests.
- **Re-slicing a model designed for another printer can now keep the designer's print settings (#2622, reporter @kpp39)** — Published models often deviate from the stock Bambu profile on purpose: five walls, 100% infill, a 0.1mm first layer. Re-slicing one for a different printer discarded all of it, because the picked process preset overrides the file's embedded settings — that override is exactly what makes cross-printer re-slicing work, so it could not simply be dropped. "Slice as designed" (#2611) was no help here: it is all-or-nothing and only offered when your printer already matches the design's target. The slice dialog now shows a **Keep the designer's settings** panel listing precisely which print settings the author changed away from the stock profile, and what each was set to, with a checkbox per setting. **Design-intent settings** — wall count, infill density and pattern, layer and first-layer height, supports, seam position, brim, ironing — are ticked by default. **Printer-specific ones** — every speed and acceleration, jerk, fan speeds, temperatures, prime-tower geometry — are listed with a badge but start unticked, because a value tuned for the author's machine can be merely wrong on yours or outside the range your printer's profile accepts, which fails the slice outright. Nothing is guessed: Bambu Studio records the deviating-settings list inside the 3MF itself, so the panel shows the author's own change list. Only ticked settings are sent, only the process slot is carried (your filament picks are untouched), and the panel is hidden for files that change nothing. Translated in all locales; wiki updated. Covered by backend and frontend tests.
@@ -111,7 +116,7 @@ All notable changes to Bambuddy will be documented in this file.
### Security
- **Patched two build-time frontend dependencies flagged by `npm audit` (GHSA-r28c-9q8g-f849, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895)** — `postcss` 8.5.15 → 8.5.23 fixes a path traversal in its source-map auto-loader (`sourceMappingURL`) that could disclose arbitrary `.map` files, and `brace-expansion` (pulled in transitively by `eslint` via `minimatch`) is bumped through the existing `overrides` block (`^5.0.7` → `^5.0.9`) for a denial-of-service via unbounded expansion. The first `brace-expansion` advisory was answered in 5.0.8 by capping the length of the combined result, but that cap covered only the accumulator the results are merged into and not the two intermediate arrays that feed it — so a small brace pattern could still exhaust the heap, fatally and beyond the reach of a `try`/`catch`, or stall the event loop for minutes. 5.0.9 bounds both arrays as they are built. Both packages are build/lint-time tooling only — neither is part of the shipped app, so no running Bambuddy install was exposed. `postcss` moved within its existing range; `brace-expansion` needed the pin because `npm audit fix` can't lift `eslint` to the patched transitive on its own.
- **Pinned `react-router` to its most-patched 7.x (7.18.1) and documented the one remaining, unreachable advisory (GHSA-qwww-vcr4-c8h2)** — Staying current on the 7.x line matters: 7.18.1 clears 14 advisories that older 7.x releases carry, several reachable in a browser SPA (open-redirect XSS in `<Link>`/`useNavigate`, route-matching DoS). The single advisory that still flags 7.18.1 — a CSRF bypass — applies only to React Router's **RSC mode**, which requires the server runtime (`@react-router/server`, not installed); Bambuddy is a Vite SPA using `BrowserRouter`, so the vulnerable path is unreachable. There is no non-major fix (the patch landed only in the 8.3.0 major, and `react-router-dom` has no 8.x — adopting it would mean migrating every import to `react-router` plus a React peer bump), so `react-router`/`react-router-dom` are pinned to 7.18.1 and the finding is carried as a documented, fail-closed exception in the CI audit gate: a *different* react-router advisory still fails CI, and the exemption is dropped automatically the moment a non-major fix ships. `npm audit fix --force` is deliberately avoided — its suggested "fix" is a downgrade to 7.11.0, which reintroduces those 14 advisories.
- **Security hardening (security-issue #9)**
## [1.2.5.2] - 2026-08-02
+239
View File
@@ -0,0 +1,239 @@
"""API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
import logging
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import RequirePermissionIfAuthEnabled
from backend.app.core.database import get_db
from backend.app.core.permissions import Permission
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.models.user import User
from backend.app.schemas.printer_ha_sensor import (
HADisplayEntity,
PrinterHASensorCreate,
PrinterHASensorReading,
PrinterHASensorResponse,
PrinterHASensorUpdate,
)
from backend.app.services.ha_sensor_manager import ha_sensor_manager
from backend.app.services.homeassistant import homeassistant_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
# These reuse the smart-plug permissions rather than introducing their own.
# Both surfaces are "the Home Assistant integration", and a brand-new
# permission would be missing from every existing custom role — users who can
# manage plugs today would silently lose access to the sensors next to them.
_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
"""Take a first reading without letting it fail the write that preceded it.
The sensor row is committed before this runs. A failure here costs the card
one poll interval of blank state, which is not worth turning a successful
save into an error response.
"""
try:
await ha_sensor_manager.refresh_one(db, sensor)
except Exception as e:
logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
@router.get("/", response_model=list[PrinterHASensorResponse])
async def list_ha_sensors(
printer_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
"""List configured sensors, grouped by printer and in display order."""
query = select(PrinterHASensor)
if printer_id is not None:
query = query.where(PrinterHASensor.printer_id == printer_id)
result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
return list(result.scalars().all())
# Must precede /{sensor_id} so "entities" is not parsed as an id.
@router.get("/entities", response_model=list[HADisplayEntity])
async def list_bindable_entities(
search: str | None = None,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
"""List the Home Assistant entities that can be bound to a printer."""
from backend.app.api.routes.settings import get_homeassistant_settings
ha_settings = await get_homeassistant_settings(db)
if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
raise HTTPException(
400,
"Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
)
entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
return [HADisplayEntity(**e) for e in entities]
@router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
async def get_printer_sensor_readings(
printer_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
"""Live state of a printer's card-visible sensors.
Served from the poller's cache, so a page full of printer cards costs
Home Assistant nothing. A sensor the poller has not reached yet falls back
to its last persisted state, marked unreachable, rather than vanishing
from the card on every restart.
"""
result = await db.execute(
select(PrinterHASensor)
.where(
PrinterHASensor.printer_id == printer_id,
PrinterHASensor.show_on_printer_card.is_(True),
)
.order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
)
readings = []
for sensor in result.scalars().all():
cached = ha_sensor_manager.get_reading(sensor.id)
readings.append(
PrinterHASensorReading(
id=sensor.id,
name=sensor.name,
entity_id=sensor.entity_id,
kind=sensor.kind,
device_class=sensor.device_class,
unit=sensor.unit,
state=cached.state if cached else sensor.last_state,
value=cached.value if cached else None,
alerting=cached.alerting if cached else False,
block_print=sensor.block_print,
reachable=cached.reachable if cached else False,
last_changed=sensor.last_changed,
)
)
return readings
@router.post("/", response_model=PrinterHASensorResponse)
async def create_ha_sensor(
data: PrinterHASensorCreate,
db: AsyncSession = Depends(get_db),
_: User | None = _CREATE,
):
"""Bind a Home Assistant entity to a printer."""
printer = await db.get(Printer, data.printer_id)
if not printer:
raise HTTPException(404, "Printer not found")
existing = await db.execute(
select(PrinterHASensor).where(
PrinterHASensor.printer_id == data.printer_id,
PrinterHASensor.entity_id == data.entity_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
sensor = PrinterHASensor(**data.model_dump())
db.add(sensor)
await db.commit()
await db.refresh(sensor)
logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name)
# Read it once now so the card shows a state immediately instead of after
# the next poll tick. Best-effort: the row is already committed, so letting
# a Home Assistant hiccup 500 the request would report a failure for work
# that succeeded — and the retry would come back "already bound".
await _refresh_quietly(sensor, db)
return sensor
@router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
async def get_ha_sensor(
sensor_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _READ,
):
sensor = await db.get(PrinterHASensor, sensor_id)
if not sensor:
raise HTTPException(404, "Sensor not found")
return sensor
@router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
async def update_ha_sensor(
sensor_id: int,
data: PrinterHASensorUpdate,
db: AsyncSession = Depends(get_db),
_: User | None = _UPDATE,
):
sensor = await db.get(PrinterHASensor, sensor_id)
if not sensor:
raise HTTPException(404, "Sensor not found")
updates = data.model_dump(exclude_unset=True)
# Re-run the create-time rules against the merged row. A PATCH that only
# sets block_print has no entity_id or alert_state in its payload, so the
# schema alone cannot tell whether the result is coherent.
merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields}
merged.update(updates)
try:
PrinterHASensorCreate(**merged)
except ValueError as e:
raise HTTPException(422, str(e)) from e
# Same uniqueness rule as create: repointing a sensor at an entity the
# printer already has would leave two rows fighting over one pill.
new_entity = updates.get("entity_id")
if new_entity and new_entity != sensor.entity_id:
clash = await db.execute(
select(PrinterHASensor).where(
PrinterHASensor.printer_id == sensor.printer_id,
PrinterHASensor.entity_id == new_entity,
PrinterHASensor.id != sensor.id,
)
)
if clash.scalar_one_or_none():
raise HTTPException(400, f"{new_entity} is already bound to this printer")
for field, value in updates.items():
setattr(sensor, field, value)
await db.commit()
await db.refresh(sensor)
# The entity or its alert rule may have changed under the cached reading.
await _refresh_quietly(sensor, db)
return sensor
@router.delete("/{sensor_id}")
async def delete_ha_sensor(
sensor_id: int,
db: AsyncSession = Depends(get_db),
_: User | None = _DELETE,
):
sensor = await db.get(PrinterHASensor, sensor_id)
if not sensor:
raise HTTPException(404, "Sensor not found")
name = sensor.name
await db.delete(sensor)
await db.commit()
ha_sensor_manager.forget(sensor_id)
logger.info("Removed HA sensor '%s'", name)
return {"message": f"Sensor '{name}' removed"}
+13
View File
@@ -279,6 +279,7 @@ async def init_db():
print_log,
print_queue,
printer,
printer_ha_sensor,
printer_sensor_history,
project,
project_bom,
@@ -3996,6 +3997,18 @@ async def run_migrations(conn):
)
await _migrate_backfill_variant_groups(conn)
# Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors
# table itself is new, so create_all() builds it; only the provider opt-in
# column needs adding to existing databases.
#
# DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default
# for a boolean column, and _safe_execute swallows the DatatypeMismatchError
# — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on
# Postgres and only work there because create_all() builds the column on a
# fresh install. SQLite has understood FALSE since 3.23, so this spelling
# is the one that actually applies on both.
await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
async def _migrate_backfill_variant_groups(conn) -> None:
"""Build variant groups from the slice provenance already on disk (#671 / #2570).
+28 -1
View File
@@ -33,6 +33,7 @@ from backend.app.api.routes import (
firmware,
github_backup,
groups,
ha_sensors,
inventory,
kprofiles,
labels,
@@ -96,6 +97,7 @@ from backend.app.services.bambu_ftp import (
)
from backend.app.services.bambu_mqtt import PrinterState
from backend.app.services.github_backup import github_backup_service
from backend.app.services.ha_sensor_manager import ha_sensor_manager
from backend.app.services.homeassistant import homeassistant_service
from backend.app.services.library_trash import library_trash_service
from backend.app.services.local_backup import local_backup_service
@@ -1277,9 +1279,29 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
# Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
# Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
#
# The filament identity fields are here because Configure Slot writes
# exactly those and nothing else. Re-configuring a slot from PLA to another
# brand or colour of PLA leaves id/tray_type/state identical, so the key
# matched, this function returned before broadcasting, and the card kept
# showing the old filament until the 30s fallback poll or a page reload —
# even though the configure route asks the printer for a fresh pushall and
# that push does carry the new values. Reset always worked, because it
# clears tray_type.
#
# These fields only change when someone configures a slot or swaps a spool,
# so unlike temperature or progress they add no broadcast traffic mid-print.
ams_tray_key = (
tuple(
(t.get("id"), t.get("tray_type", ""), t.get("state"))
(
t.get("id"),
t.get("tray_type", ""),
t.get("state"),
t.get("tray_color", ""),
t.get("tray_info_idx", ""),
t.get("tray_sub_brands", ""),
t.get("cali_idx"),
)
for a in (state.raw_data.get("ams") or [])
for t in a.get("tray", [])
)
@@ -7344,6 +7366,9 @@ async def lifespan(app: FastAPI):
# Start the smart plug scheduler for time-based on/off
smart_plug_manager.start_scheduler()
# Start the Home Assistant sensor poller (#1148)
ha_sensor_manager.start()
# Resume any pending auto-offs that were interrupted by restart
await smart_plug_manager.resume_pending_auto_offs()
@@ -7422,6 +7447,7 @@ async def lifespan(app: FastAPI):
# Shutdown
print_scheduler.stop()
smart_plug_manager.stop_scheduler()
ha_sensor_manager.stop()
notification_service.stop_digest_scheduler()
github_backup_service.stop_scheduler()
local_backup_service.stop_scheduler()
@@ -7922,6 +7948,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix)
app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
app.include_router(ha_sensors.router, prefix=app_settings.api_prefix)
app.include_router(print_log.router, prefix=app_settings.api_prefix)
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
+2
View File
@@ -21,6 +21,7 @@ from backend.app.models.pending_upload import PendingUpload
from backend.app.models.pipeline_run import PipelineJob, PipelineRun
from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.models.printer_sensor_history import PrinterSensorHistory
from backend.app.models.project import Project
from backend.app.models.settings import Settings
@@ -56,6 +57,7 @@ __all__ = [
"APIKey",
"AMSSensorHistory",
"PrinterSensorHistory",
"PrinterHASensor",
"AmsLabel",
"PendingUpload",
"PrintBatch",
+3
View File
@@ -82,6 +82,9 @@ class NotificationProvider(Base):
on_ams_ht_humidity_high = Column(Boolean, default=False) # AMS-HT humidity above threshold
on_ams_ht_temperature_high = Column(Boolean, default=False) # AMS-HT temperature above threshold
# Event triggers - Home Assistant sensors bound to a printer (#1148)
on_ha_sensor_alert = Column(Boolean, default=False) # Bound HA sensor entered its alert state
# Event triggers - Build plate detection
on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
# Off by default: fires after every print, alongside the print-complete alert (#2525)
@@ -121,6 +121,12 @@ DEFAULT_TEMPLATES = [
"title_template": "Bed Cooled",
"body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
},
{
"event_type": "ha_sensor_alert",
"name": "Home Assistant Sensor Alert",
"title_template": "Sensor Alert",
"body_template": "{printer}: {sensor} is {state}",
},
{
"event_type": "first_layer_complete",
"name": "First Layer Complete",
+2
View File
@@ -61,6 +61,7 @@ class Printer(Base):
sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
back_populates="printer", cascade="all, delete-orphan"
)
ha_sensors: Mapped[list["PrinterHASensor"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
from backend.app.models.ams_history import AMSSensorHistory # noqa: E402
@@ -68,5 +69,6 @@ from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.kprofile_note import KProfileNote # noqa: E402
from backend.app.models.maintenance import PrinterMaintenance # noqa: E402
from backend.app.models.notification import NotificationProvider # noqa: E402
from backend.app.models.printer_ha_sensor import PrinterHASensor # noqa: E402
from backend.app.models.printer_sensor_history import PrinterSensorHistory # noqa: E402
from backend.app.models.smart_plug import SmartPlug # noqa: E402
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class PrinterHASensor(Base):
"""A read-only Home Assistant entity bound to a printer (#1148, #448).
Deliberately *not* a ``SmartPlug`` row with a wider entity pattern. A plug
carries auto-on/auto-off, schedules, power alerts, energy snapshots and
``controls_printer_power``; none of that means anything for a door contact,
and ``get_smart_plug_by_printer`` would hand the card's power button a
sensor to switch. Sensors get their own table and their own read-only
routes instead.
Not to be confused with ``PrinterSensorHistory``, which stores the
printer's *own* heater readings.
"""
__tablename__ = "printer_ha_sensors"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), index=True)
name: Mapped[str] = mapped_column(String(100))
entity_id: Mapped[str] = mapped_column(String(255))
# "binary" for binary_sensor.*, "numeric" for sensor.*. Decides how the
# state is rendered and which alert fields apply.
kind: Mapped[str] = mapped_column(String(16), default="binary")
# HA's own device_class, snapshotted when the entity is bound. Drives the
# on/off wording (door -> Open/Closed, motion -> Detected/Clear) and the
# icon, so the card doesn't have to say "On" for an open door.
device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Numeric only: "°C", "%", "ppm", ... shown next to the value.
unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
# What counts as needing attention. One notion, three consumers: the pill
# colour on the card, the notification, and the print interlock.
# Binary sensors use alert_state ("on"/"off"/None), numeric ones the
# thresholds. All None means "just show the value".
alert_state: Mapped[str | None] = mapped_column(String(8), nullable=True)
alert_above: Mapped[float | None] = mapped_column(Float, nullable=True)
alert_below: Mapped[float | None] = mapped_column(Float, nullable=True)
# Hold queued prints for this printer while the sensor is in its alert
# state — the enclosure-door case this feature was asked for. Opt-in, and
# only ever a *hold*: the item stays pending with a waiting_reason and
# dispatches by itself once the door closes.
block_print: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# Last poll result. Persisted so a restart doesn't blank the card until the
# first poll lands, and so notifications only fire on a real transition.
last_state: Mapped[str | None] = mapped_column(String(64), nullable=True)
last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
printer: Mapped["Printer"] = relationship(back_populates="ha_sensors")
from backend.app.models.printer import Printer # noqa: E402
+8
View File
@@ -61,6 +61,11 @@ class NotificationProviderBase(BaseModel):
default=False, description="Notify when AMS-HT temperature exceeds threshold"
)
# Event triggers - Home Assistant sensors (#1148)
on_ha_sensor_alert: bool = Field(
default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
)
# Event triggers - Build plate detection
on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
on_plate_clear_required: bool = Field(
@@ -148,6 +153,9 @@ class NotificationProviderUpdate(BaseModel):
on_ams_ht_humidity_high: bool | None = None
on_ams_ht_temperature_high: bool | None = None
# Event triggers - Home Assistant sensors (#1148)
on_ha_sensor_alert: bool | None = None
# Event triggers - Build plate detection
on_plate_not_empty: bool | None = None
on_plate_clear_required: bool | None = None
@@ -23,6 +23,7 @@ class EventType(StrEnum):
AMS_HUMIDITY_HIGH = "ams_humidity_high"
AMS_TEMPERATURE_HIGH = "ams_temperature_high"
BED_COOLED = "bed_cooled"
HA_SENSOR_ALERT = "ha_sensor_alert"
TEST = "test"
@@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
"ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
"ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
"bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
"ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
"test": ["app_name", "timestamp"],
# Queue notifications
"queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -205,6 +207,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"ha_sensor_alert": {
"printer": "Bambu X1C",
"sensor": "Enclosure Door",
"state": "open",
"timestamp": "2024-01-15 14:30",
"app_name": "Bambuddy",
},
"test": {
"app_name": "Bambuddy",
"timestamp": "2024-01-15 14:30",
+114
View File
@@ -0,0 +1,114 @@
"""Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class PrinterHASensorBase(BaseModel):
printer_id: int
name: str = Field(..., min_length=1, max_length=100)
entity_id: str = Field(..., pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
kind: Literal["binary", "numeric"] = "binary"
device_class: str | None = Field(default=None, max_length=32)
unit: str | None = Field(default=None, max_length=16)
alert_state: Literal["on", "off"] | None = None
alert_above: float | None = None
alert_below: float | None = None
block_print: bool = False
notify_on_alert: bool = False
show_on_printer_card: bool = True
sort_order: int = Field(default=0, ge=0, le=999)
@model_validator(mode="after")
def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
domain = self.entity_id.split(".")[0]
expected = "binary" if domain == "binary_sensor" else "numeric"
if self.kind != expected:
raise ValueError(f"kind must be '{expected}' for a {domain} entity")
# Alert fields are per-kind: a threshold on a door contact and an
# on/off alert on a thermometer are both configuration the poller
# would silently ignore, so reject them at the edge instead.
if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
raise ValueError("alert_above/alert_below only apply to numeric sensors")
if self.kind == "numeric" and self.alert_state is not None:
raise ValueError("alert_state only applies to binary sensors")
if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
raise ValueError("alert_below must be lower than alert_above")
# An interlock or a notification with nothing to trigger on would never
# fire — that reads as a broken feature, not as a no-op.
if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
raise ValueError("block_print and notify_on_alert require an alert condition")
return self
def _has_alert_condition(self) -> bool:
return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
class PrinterHASensorCreate(PrinterHASensorBase):
pass
class PrinterHASensorUpdate(BaseModel):
"""Partial update. Validated against the merged row in the route, because
the per-kind rules above need fields this payload may not carry."""
name: str | None = Field(default=None, min_length=1, max_length=100)
entity_id: str | None = Field(default=None, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
kind: Literal["binary", "numeric"] | None = None
device_class: str | None = Field(default=None, max_length=32)
unit: str | None = Field(default=None, max_length=16)
alert_state: Literal["on", "off"] | None = None
alert_above: float | None = None
alert_below: float | None = None
block_print: bool | None = None
notify_on_alert: bool | None = None
show_on_printer_card: bool | None = None
sort_order: int | None = Field(default=None, ge=0, le=999)
class PrinterHASensorResponse(PrinterHASensorBase):
id: int
last_state: str | None = None
last_changed: datetime | None = None
last_checked: datetime | None = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class PrinterHASensorReading(BaseModel):
"""One sensor's live state, as the printer card renders it."""
id: int
name: str
entity_id: str
kind: str
device_class: str | None = None
unit: str | None = None
# Raw HA state: "on"/"off" for binary, the numeric string for sensors.
# None when the entity is unavailable or has not been polled yet.
state: str | None = None
value: float | None = None # numeric sensors only, parsed from state
alerting: bool = False
block_print: bool = False
reachable: bool = True
last_changed: datetime | None = None
class HADisplayEntity(BaseModel):
"""A bindable entity, as offered by the picker."""
entity_id: str
friendly_name: str
state: str | None = None
domain: str
device_class: str | None = None
unit_of_measurement: str | None = None
+90 -7
View File
@@ -46,6 +46,16 @@ _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
# are deliberately excluded — those SHOULD end it.
_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3}) # Checking, Drying, Cooling
# A drying cycle that runs to term ends with its countdown all but exhausted, so
# the last dry_time we saw before the drop to 0 tells us whether the firmware
# ended the cycle on schedule or aborted it. More than this many minutes still on
# the clock means it was cut short, and the firmware's own reason codes are worth
# capturing at INFO — #2770 aborted a 12-hour cycle 20 minutes in (700 minutes
# left), and the log said only "drying complete", so the report carried no
# evidence of why. The margin absorbs a stale last observation between AMS
# pushes; it is not a judgement about how short "short" is.
_EARLY_DRY_END_MINUTES = 5
# CONNACK reason codes that mean the printer actively refused our credentials,
# as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
# single-byte CONNACK return codes paho maps onto the v5 reason-code space:
@@ -750,6 +760,11 @@ class BambuMQTTClient:
# — only the dry_time countdown — so we cache what we sent to drive
# the UI badge. Cleared on stop or on the dry_time falling edge to 0.
self._drying_targets: dict[int, dict[str, object]] = {}
# AMS ids we have sent a stop for and not yet seen end. A stop always
# ends a cycle far short of its duration, which on the telemetry alone
# is indistinguishable from the firmware abandoning it — so the cycle-end
# log would otherwise blame the printer for our own decision (#2770).
self._drying_stops_sent: set[int] = set()
self.state = PrinterState()
self._client: mqtt.Client | None = None
@@ -2819,13 +2834,7 @@ class BambuMQTTClient:
previous = self._previous_dry_times.get(ams_id, 0)
self._previous_dry_times[ams_id] = current
if previous > 0 and current == 0:
logger.info(
"[%s] AMS %d drying complete (dry_time %d → 0)",
self.serial_number,
ams_id,
previous,
)
self._drying_targets.pop(ams_id, None)
self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
if self.on_drying_complete:
self.on_drying_complete(ams_id)
@@ -2865,6 +2874,71 @@ class BambuMQTTClient:
if self._pending_assignments:
self._check_assignment_verifications()
def _log_drying_cycle_end(
self,
ams_id: int,
remaining: int,
ams_unit: dict,
target: dict[str, object] | None,
) -> None:
"""Report a finished drying cycle, with the firmware's reason when it was
cut short (#2770).
A cycle that reaches its configured duration needs no explanation and
keeps the one-line "drying complete" it has always had. One that ends
with most of its countdown left was ended by somebody, and there are
only two candidates: a stop Bambuddy sent — the print-takes-priority
stop, or the user's Stop button — which is named as such, or the
firmware.
For the firmware case the only account of why lives in fields we already
parse but have never written down: the ``dry_status`` /
``dry_sub_status`` phase from the info hex, the per-unit
``dry_sf_reason`` constraint codes, and whatever HMS errors are live at
that moment. Logging them at INFO puts them in every support bundle by
default, which is what a report like #2770 needs before its cause can be
argued about at all.
"""
if ams_id in self._drying_stops_sent:
self._drying_stops_sent.discard(ams_id)
logger.info(
"[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0)",
self.serial_number,
ams_id,
remaining,
)
return
if remaining <= _EARLY_DRY_END_MINUTES:
logger.info(
"[%s] AMS %d drying complete (dry_time %d → 0)",
self.serial_number,
ams_id,
remaining,
)
return
requested_minutes: int | None = None
if target is not None:
try:
requested_minutes = int(target.get("duration_hours") or 0) * 60 or None
except (TypeError, ValueError):
requested_minutes = None
logger.info(
"[%s] AMS %d drying ended early — %d of %s minutes still on the clock. "
"Bambuddy sent no stop command, so the firmware ended this cycle: "
"dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s",
self.serial_number,
ams_id,
remaining,
requested_minutes if requested_minutes is not None else "?",
ams_unit.get("dry_status"),
ams_unit.get("dry_sub_status"),
ams_unit.get("dry_sf_reason") or [],
[e.full_code for e in self.state.hms_errors] or "none",
)
def register_assignment_verification(
self,
ams_id: int,
@@ -5492,13 +5566,22 @@ class BambuMQTTClient:
)
# Track the active-cycle target so the badge can show "PETG @ 65°C"
# while drying. Bambu only echoes dry_time on subsequent pushes.
# duration_hours is not shown anywhere; it is what lets the cycle-end log
# say how much of the requested time the firmware actually ran (#2770).
if mode == 1:
self._drying_targets[ams_id] = {
"filament": filament or "",
"temp": int(temp),
"duration_hours": int(duration),
}
self._drying_stops_sent.discard(ams_id)
else:
self._drying_targets.pop(ams_id, None)
# Remember that this cycle's end is ours, so the cycle-end log
# attributes it to Bambuddy instead of to the firmware (#2770). A
# stop always ends the cycle far short of its duration, which is
# otherwise indistinguishable from the firmware abandoning it.
self._drying_stops_sent.add(ams_id)
return True
@staticmethod
+158 -51
View File
@@ -9,9 +9,11 @@ to ensure they are well-formed before use.
import asyncio
import functools
import ipaddress
import logging
import re
import shutil
import socket
from collections.abc import AsyncGenerator, Callable
from pathlib import Path
from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
logger = logging.getLogger(__name__)
# Protocols ffmpeg may use for an RTSP input. RTSP negotiates its media
# transport at runtime, so the transports have to be here alongside rtsp itself;
# tls and crypto cover encrypted variants. Everything ffmpeg would otherwise
# accept behind an -i — file, http, tcp to anywhere, concat — is left out, so a
# stream that references something outside itself cannot pull it in.
_RTSP_PROTOCOL_WHITELIST = "rtsp,rtp,udp,tcp,tls,crypto"
def _blocked_host_reason(hostname: str) -> str | None:
"""Describe why *hostname* is a destination we refuse to fetch, or None to allow it.
Camera URLs are user-supplied and reach the network — over aiohttp for the
HTTP types, and as an ``ffmpeg -i`` argument for RTSP — so this is where the
SSRF boundary sits. LAN addresses are deliberately allowed: cameras live on
the same network as Bambuddy, and blocking RFC-1918 would remove the feature
rather than protect it. What is left to refuse is the host talking to
itself, the unspecified address, link-local (which is where the cloud
metadata endpoint lives), and the metadata hostnames.
IP literals are classified with ``ipaddress`` rather than compared against a
list of spellings, because 127.0.0.1, 127.0.0.2, 2130706433, 0177.0.0.1,
127.1 and ::ffff:127.0.0.1 all arrive at loopback and a list of strings only
ever catches whichever one someone thought to write down. ``inet_aton``
comes first because it accepts the legacy octal, decimal and short forms
that ``ip_address`` rejects — the C resolvers behind aiohttp and ffmpeg
accept them, so refusing to understand them here would only mean not seeing
where the request is actually going.
"""
host = hostname.lower()
ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
try:
ip = ipaddress.ip_address(socket.inet_aton(host))
except OSError:
try:
ip = ipaddress.ip_address(host)
except ValueError:
ip = None
if ip is None:
# A name, not an address. It is not resolved here on purpose: aiohttp
# and ffmpeg each resolve independently afterwards, so a check here
# decides nothing about where they end up (DNS rebinding), while a
# lookup on every capture would break LAN cameras behind slow or
# intermittent local DNS.
if host == "localhost" or host.endswith(".localhost"):
return "localhost"
if host in ("metadata.google.internal", "metadata.google"):
return "a cloud metadata service"
return None
# ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling.
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None:
ip = mapped
if ip.is_loopback:
return "loopback"
if ip.is_unspecified:
return "the unspecified address"
if ip.is_link_local:
return "a link-local address (the cloud metadata range)"
return None
def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
"""Validate and sanitize camera URL, returning a safe reconstructed URL.
This validates that the URL is well-formed, uses an allowed scheme,
does not target cloud metadata services, and returns a reconstructed
URL from validated components.
This validates that the URL is well-formed, uses an allowed scheme, does not
target the host itself or a cloud metadata service, and returns a URL
reconstructed from the validated components.
Note: This intentionally allows user-provided URLs as that is the
purpose of external camera configuration. Local network IPs are
@@ -51,37 +117,35 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
if scheme not in allowed_schemes:
return None
# Block cloud metadata service endpoints (SSRF mitigation)
# These are dangerous destinations that should never be accessed
hostname = parsed.hostname or ""
hostname_lower = hostname.lower()
blocked_hosts = (
"169.254.169.254", # AWS/GCP/Azure metadata
"metadata.google.internal", # GCP metadata
"metadata.google",
"localhost", # Block localhost to prevent internal service access
"127.0.0.1",
"::1",
"0.0.0.0", # nosec B104
)
if hostname_lower in blocked_hosts:
logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
if not hostname:
return None
# Block link-local addresses (169.254.x.x)
if hostname.startswith("169.254."):
logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
blocked = _blocked_host_reason(hostname)
if blocked:
logger.warning("Blocked camera URL targeting %s: %s", blocked, hostname)
return None
# Reconstruct URL from validated components to break taint chain
# This creates a new string from validated parts
#
# The credentials are carried across verbatim from netloc rather than
# via parsed.username/.password, which urlparse has already percent-
# decoded: re-emitting those would corrupt any password containing an
# @ or a :. They have to survive at all because most RTSP cameras — and
# a fair number of MJPEG ones — carry their login in the URL, and
# dropping it turns every one of them into an authentication failure.
netloc = parsed.netloc
userinfo = f"{netloc.rsplit('@', 1)[0]}@" if "@" in netloc else ""
# parsed.hostname has already stripped the brackets off an IPv6 literal;
# without them back the result is not a URL any client can parse.
host_str = f"[{hostname}]" if ":" in hostname else hostname
port_str = f":{parsed.port}" if parsed.port else ""
path = parsed.path or ""
query = f"?{parsed.query}" if parsed.query else ""
fragment = f"#{parsed.fragment}" if parsed.fragment else ""
# Build sanitized URL from validated components
sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
sanitized = f"{scheme}://{userinfo}{host_str}{port_str}{path}{query}{fragment}"
return sanitized
except ValueError:
return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
return None
async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
"""Capture frame from USB camera using ffmpeg."""
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
logger.error("ffmpeg not found - required for USB camera capture")
return None
def _safe_usb_device_path(device: str) -> str | None:
"""Rebuild a /dev/videoN path from a validated device number, or None.
# Validate device path - must be /dev/videoN format where N is 0-99
# This prevents path traversal by using a strict allowlist approach
import re as regex_module
Validate device path - must be /dev/videoN format where N is 0-99. This
prevents path traversal by using a strict allowlist approach: the returned
path is built from an integer, which cannot carry a traversal, rather than
from any part of the caller's string.
device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
Returns None if the device does not exist, so a caller cannot hand ffmpeg a
path to something that is not a device node.
"""
device_match = re.match(r"^/dev/video(\d{1,2})$", device)
if not device_match:
logger.error("Invalid USB device path format: %s", device)
return None
@@ -399,9 +463,6 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
# Convert to integer to break taint chain - integers cannot contain path traversal
# lgtm[py/path-injection] - device_num is validated integer 0-99
device_num = int(device_match.group(1)) # Safe: regex guarantees 1-2 digits
if device_num > 99:
logger.error("USB device number out of range: %s", device_num)
return None
# Construct safe path from validated integer (completely untainted)
safe_device_path = Path(f"/dev/video{device_num}") # lgtm[py/path-injection]
@@ -410,8 +471,22 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
logger.error("USB device does not exist: %s", safe_device_path)
return None
return str(safe_device_path) # lgtm[py/path-injection]
async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
"""Capture frame from USB camera using ffmpeg."""
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
logger.error("ffmpeg not found - required for USB camera capture")
return None
safe_device = _safe_usb_device_path(device)
if not safe_device:
return None
# Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
device = str(safe_device_path) # lgtm[py/path-injection]
device = safe_device # lgtm[py/path-injection]
# Use ffmpeg to grab a single frame from USB camera
cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
"""Capture frame from RTSP using ffmpeg.
For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
Note: this function intentionally connects to user-configured URLs, the same
as the MJPEG and snapshot paths. The URL is sanitized and dangerous
destinations are blocked before it reaches ffmpeg.
"""
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
logger.error("ffmpeg not found - required for RTSP capture")
return None
# ffmpeg's -i accepts every protocol it was built with, so an unchecked URL
# here is a request to any host and scheme the caller names, not merely to a
# camera. Restricting the scheme to RTSP is what keeps this a camera fetch.
safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
if not safe_url:
logger.error("Invalid RTSP URL: %s...", redact_url_credentials(url)[:50])
return None
# If rtsps://, use TLS proxy
proxy_server = None
effective_url = url
if url.lower().startswith("rtsps://"):
effective_url = safe_url
if safe_url.lower().startswith("rtsps://"):
try:
from urllib.parse import urlparse
from backend.app.services.camera import create_tls_proxy
parsed = urlparse(url)
parsed = urlparse(safe_url)
target_port = parsed.port or 322
proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
if parsed.password:
userinfo += f":{parsed.password}"
userinfo += "@"
# Points at loopback deliberately, and is built after the check
# above rather than re-checked: the destination that mattered was
# the one the caller named, and it has already been vetted.
effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
if parsed.query:
effective_url += f"?{parsed.query}"
except Exception as e:
logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
effective_url = url
effective_url = safe_url
cmd = [
ffmpeg,
"-rtsp_transport",
"tcp",
# Belt and braces on the scheme check above: a demuxer that follows a
# reference out of the stream cannot leave these protocols either.
"-protocol_whitelist",
_RTSP_PROTOCOL_WHITELIST,
"-i",
effective_url,
"-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
of relying on ffmpeg's GnuTLS backend, which has compatibility issues
with some printer firmwares.
Note: this function intentionally connects to user-configured URLs. The URL
is sanitized and dangerous destinations are blocked before it reaches
ffmpeg — see ``_capture_rtsp_frame``, which guards the one-shot path the
same way.
"""
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
from backend.app.services.camera import rtsp_socket_timeout_flag
safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
if not safe_url:
logger.error("Invalid RTSP stream URL: %s...", redact_url_credentials(url)[:50])
return
# If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
proxy_server = None
effective_url = url
if url.lower().startswith("rtsps://"):
effective_url = safe_url
if safe_url.lower().startswith("rtsps://"):
try:
from urllib.parse import urlparse
from backend.app.services.camera import create_tls_proxy
parsed = urlparse(url)
parsed = urlparse(safe_url)
target_port = parsed.port or 322
proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
# Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
@@ -983,12 +1087,14 @@ async def _stream_rtsp(
if parsed.password:
userinfo += f":{parsed.password}"
userinfo += "@"
# Loopback by design, and built after the check above rather than
# re-checked — see the same rewrite in _capture_rtsp_frame.
effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
if parsed.query:
effective_url += f"?{parsed.query}"
except Exception as e:
logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
effective_url = url
effective_url = safe_url
cmd = [
ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
"tcp",
"-rtsp_flags",
"prefer_tcp",
"-protocol_whitelist",
_RTSP_PROTOCOL_WHITELIST,
# Socket I/O timeout name varies by ffmpeg version (#1504); see
# `rtsp_socket_timeout_flag()` in services.camera.
f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
logger.error("ffmpeg not found - required for USB camera streaming")
return
# Validate device path
if not device.startswith("/dev/video"):
logger.error("Invalid USB device path: %s", device)
return
if not Path(device).exists():
logger.error("USB device does not exist: %s", device)
# Same validation as the one-shot path: a prefix check accepted
# /dev/video/../../<anything that exists>, which -f v4l2 would then refuse
# rather than the check refusing it.
safe_device = _safe_usb_device_path(device)
if not safe_device:
return
device = safe_device
# ffmpeg command to stream from USB camera (v4l2)
cmd = [
+271
View File
@@ -0,0 +1,271 @@
"""Polls the Home Assistant entities bound to printers (#1148, #448).
One background loop reads every configured entity on a fixed cadence and keeps
the result in memory. Three things consume it:
* the printer card, which reads the cache instead of hitting Home Assistant
once per card per refresh;
* notifications, fired on a transition *into* the alert state, never on every
poll while it persists;
* the print interlock, which holds queued jobs for a printer while one of its
sensors is alerting.
Everything degrades to "no opinion" when Home Assistant cannot be reached: an
unreadable sensor never alerts, never notifies, and never holds a print. A
door contact that stops responding must not strand the queue.
"""
import asyncio
import logging
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.models.printer import Printer
from backend.app.models.printer_ha_sensor import PrinterHASensor
from backend.app.services.homeassistant import as_float, homeassistant_service
from backend.app.utils.local_time import utcnow_naive
logger = logging.getLogger(__name__)
# Fast enough that an enclosure door reads as live, slow enough that a handful
# of tiny LAN requests stays background noise.
POLL_INTERVAL = 15
@dataclass
class SensorReading:
"""The last thing we managed to read for one sensor."""
state: str | None # raw HA state, None when unreadable
value: float | None # parsed number for numeric sensors
alerting: bool
reachable: bool
class HASensorManager:
def __init__(self):
self._task: asyncio.Task | None = None
# sensor id -> last reading. Sensors absent from this map have not been
# polled yet; callers must not read that as "not alerting" without also
# checking, which is why get_reading returns None rather than a default.
self._readings: dict[int, SensorReading] = {}
# sensor id -> alerting, from the last reading we could actually take.
# Kept apart from _readings because a dropout must not read as the
# alert clearing: on -> unavailable -> on is one continuous alert, and
# notifying off _readings alone would ping the user on every reconnect
# of a flaky contact. Absent means "never had a reachable reading".
self._last_alerting: dict[int, bool] = {}
# -- lifecycle ---------------------------------------------------------
def start(self):
if self._task is None:
self._task = asyncio.create_task(self._poll_loop())
logger.info("Home Assistant sensor poller started")
def stop(self):
if self._task:
self._task.cancel()
self._task = None
logger.info("Home Assistant sensor poller stopped")
# -- cache access ------------------------------------------------------
def get_reading(self, sensor_id: int) -> SensorReading | None:
return self._readings.get(sensor_id)
def forget(self, sensor_id: int):
"""Drop a deleted sensor's cached reading so its id cannot be reused
by a later row and answer with the old sensor's state."""
self._readings.pop(sensor_id, None)
self._last_alerting.pop(sensor_id, None)
async def blocked_printers(self, db: AsyncSession) -> dict[int, str]:
"""Printers currently held by an interlock, mapped to the sensor names.
A sensor counts only when it is configured to block, *and* was read
successfully, *and* is in its alert state. Anything we could not read
is omitted, so the queue keeps moving when Home Assistant is down.
One query for the whole fleet — the scheduler calls this on every pass,
and per-printer lookups would put a query per printer in that loop.
"""
result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True)))
blocked: dict[int, list[str]] = {}
for sensor in result.scalars().all():
reading = self._readings.get(sensor.id)
if reading and reading.reachable and reading.alerting:
blocked.setdefault(sensor.printer_id, []).append(sensor.name)
return {printer_id: ", ".join(names) for printer_id, names in blocked.items()}
# -- polling -----------------------------------------------------------
async def _poll_loop(self):
while True:
try:
await asyncio.sleep(POLL_INTERVAL)
await self.poll_once()
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("Home Assistant sensor poll failed: %s", e)
async def poll_once(self):
"""One pass over every configured sensor."""
from backend.app.core.database import async_session
async with async_session() as db:
result = await db.execute(select(PrinterHASensor))
sensors = list(result.scalars().all())
# Drop readings for rows that no longer exist. The delete route
# calls forget(), but a printer deleted with sensors attached takes
# them out by cascade, and a restored backup can renumber them —
# either way a stale id must not answer for a later sensor.
live = {s.id for s in sensors}
for stale in set(self._readings) - live:
self.forget(stale)
if not sensors:
return
if not await self._configure(db):
# Not configured is not a failure to report every 15 seconds,
# but the readings must not go stale-but-confident either.
for sensor in sensors:
self._readings[sensor.id] = SensorReading(None, None, False, False)
return
states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
await self._apply(db, sensors, states)
async def refresh_one(self, db: AsyncSession, sensor: PrinterHASensor):
"""Read a single sensor now, on the caller's session.
Used after a create or an edit so the card shows a state straight away
instead of blank until the next tick. Deliberately not a full
``poll_once``: a request handler must not wait on every configured
entity, and must not fire another user's notification as a side effect
of this one saving a form.
"""
self.forget(sensor.id)
if not await self._configure(db):
self._readings[sensor.id] = SensorReading(None, None, False, False)
return
states = await homeassistant_service.fetch_states([sensor.entity_id])
reading = evaluate(sensor, states.get(sensor.entity_id))
self._readings[sensor.id] = reading
if reading.reachable:
self._last_alerting[sensor.id] = reading.alerting
sensor.last_checked = utcnow_naive()
if reading.reachable and sensor.last_state != reading.state:
sensor.last_state = reading.state
sensor.last_changed = sensor.last_checked
await db.commit()
await db.refresh(sensor)
async def _configure(self, db: AsyncSession) -> bool:
from backend.app.api.routes.settings import get_homeassistant_settings
try:
ha_settings = await get_homeassistant_settings(db)
except Exception as e:
logger.warning("Failed to read Home Assistant settings: %s", e)
return False
if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
return False
homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
return True
async def _apply(self, db: AsyncSession, sensors: list[PrinterHASensor], states: dict[str, dict | None]):
"""Fold poll results into the cache, the DB and any notifications."""
from backend.app.services.notification_service import notification_service
now = utcnow_naive()
alerts: list[tuple[PrinterHASensor, SensorReading]] = []
for sensor in sensors:
payload = states.get(sensor.entity_id)
reading = evaluate(sensor, payload)
was_alerting = self._last_alerting.get(sensor.id)
self._readings[sensor.id] = reading
sensor.last_checked = now
if reading.reachable:
if sensor.last_state != reading.state:
sensor.last_state = reading.state
sensor.last_changed = now
# Notify on the edge into alerting only. `was_alerting is None` is
# a cold cache (first poll after a restart) — a door that was
# already open then has not just been opened, and re-announcing it
# on every restart would train users to ignore the alert.
if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
alerts.append((sensor, reading))
if reading.reachable:
self._last_alerting[sensor.id] = reading.alerting
await db.commit()
for sensor, reading in alerts:
# db.get, not sensor.printer: touching the lazy relationship from
# an async session raises MissingGreenlet.
printer = await db.get(Printer, sensor.printer_id)
try:
await notification_service.on_ha_sensor_alert(
printer_id=sensor.printer_id,
printer_name=printer.name if printer else "Unknown",
sensor_name=sensor.name,
state=describe_state(sensor, reading),
db=db,
)
except Exception as e:
logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
"""Turn one HA state payload into a reading.
Split out from the manager so the alert rules can be tested without a
poller, a database or a Home Assistant.
"""
if payload is None:
return SensorReading(state=None, value=None, alerting=False, reachable=False)
state = payload.get("state")
# HA reports these two for entities whose integration is down. Treating
# them as a state would make "unavailable" a value the card renders and
# the thresholds compare against.
if state in (None, "unknown", "unavailable"):
return SensorReading(state=None, value=None, alerting=False, reachable=False)
state = str(state)
if sensor.kind == "numeric":
value = as_float(state)
if value is None:
# A sensor that used to report numbers and now reports text is
# not a reading we can place against a threshold.
return SensorReading(state=state, value=None, alerting=False, reachable=True)
alerting = (sensor.alert_above is not None and value > sensor.alert_above) or (
sensor.alert_below is not None and value < sensor.alert_below
)
return SensorReading(state=state, value=value, alerting=alerting, reachable=True)
normalized = state.lower()
alerting = sensor.alert_state is not None and normalized == sensor.alert_state
return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
"""Human-readable state for a notification body ("open", "31.4 °C")."""
if sensor.kind == "numeric" and reading.value is not None:
return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}"
return reading.state or "unknown"
ha_sensor_manager = HASensorManager()
+102
View File
@@ -1,5 +1,6 @@
"""Service for communicating with Home Assistant via REST API."""
import asyncio
import logging
from typing import TYPE_CHECKING
from urllib.parse import urlparse
@@ -364,6 +365,107 @@ class HomeAssistantService:
logger.warning("Failed to list HA sensor entities: %s", e)
return []
async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
"""List entities that can be bound to a printer for display (#1148, #448).
Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that
carry a reading. Distinct from ``list_sensor_entities``, which exists
for a plug's energy monitoring and therefore only admits power/energy
units — an enclosure thermometer is exactly what that one filters out.
A ``sensor.*`` qualifies when it has a unit or its state parses as a
number. That drops the text sensors (``sensor.washing_machine_status``)
that the card has no way to render as a value.
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{url.rstrip('/')}/api/states",
headers={"Authorization": f"Bearer {token}"},
)
response.raise_for_status()
entities = []
search_lower = search.lower().strip() if search else None
for entity in response.json():
entity_id = entity.get("entity_id", "")
domain = entity_id.split(".")[0] if "." in entity_id else ""
if domain not in ("binary_sensor", "sensor"):
continue
attrs = entity.get("attributes", {})
unit = attrs.get("unit_of_measurement")
state = entity.get("state")
if domain == "sensor" and not unit and as_float(state) is None:
continue
friendly_name = attrs.get("friendly_name") or entity_id
if search_lower and (
search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
):
continue
entities.append(
{
"entity_id": entity_id,
"friendly_name": friendly_name,
"state": state,
"domain": domain,
"device_class": attrs.get("device_class"),
"unit_of_measurement": unit,
}
)
return sorted(entities, key=lambda x: x["friendly_name"].lower())
except Exception as e:
logger.warning("Failed to list HA display entities: %s", e)
return []
async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]:
"""Read several entities in one pass, keyed by entity_id.
One GET per entity over a shared client rather than a single
``/api/states`` sweep: the poller only ever wants a handful of bound
entities, and pulling every state in the user's Home Assistant on a
15-second cadence is a lot of payload to throw away.
A ``None`` value means that entity could not be read — the callers
treat that as "no opinion" rather than as a state, so an unreachable
Home Assistant never trips an alert or holds a print.
"""
if not entity_ids:
return {}
if not self.base_url or not self.token:
return dict.fromkeys(entity_ids)
async with httpx.AsyncClient(timeout=self.timeout) as client:
async def _one(entity_id: str) -> tuple[str, dict | None]:
try:
response = await client.get(
f"{self.base_url}/api/states/{entity_id}",
headers=self._headers(),
)
response.raise_for_status()
return entity_id, response.json()
except Exception as e:
logger.debug("Failed to read HA entity %s: %s", entity_id, e)
return entity_id, None
results = await asyncio.gather(*(_one(e) for e in entity_ids))
return dict(results)
def as_float(value) -> float | None:
"""Parse a HA state to a number, or None for "unknown"/"unavailable"/text."""
try:
return float(value)
except (TypeError, ValueError):
return None
# Singleton instance
homeassistant_service = HomeAssistantService()
@@ -1729,6 +1729,43 @@ class NotificationService:
providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
)
async def on_ha_sensor_alert(
self,
printer_id: int,
printer_name: str,
sensor_name: str,
state: str,
db: AsyncSession,
):
"""A Home Assistant sensor bound to a printer entered its alert state (#1148).
Sent immediately rather than folded into a digest: the case this exists
for is an enclosure door left open, which is only worth telling someone
about while they can still act on it.
"""
providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
if not providers:
return
variables = {
"printer": printer_name,
"sensor": sensor_name,
"state": state,
}
title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
await self._send_to_providers(
providers,
title,
message,
db,
"ha_sensor_alert",
printer_id,
printer_name,
force_immediate=True,
variables=variables,
)
async def on_first_layer_complete(
self,
printer_id: int,
+287 -39
View File
@@ -35,6 +35,7 @@ from backend.app.services.bambu_ftp import (
)
from backend.app.services.bambu_mqtt import HMS_MQTT_VERIFY_FAILED
from backend.app.services.filament_deficit import compute_deficit_for_queue_item
from backend.app.services.ha_sensor_manager import ha_sensor_manager
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
printer_manager,
@@ -396,6 +397,43 @@ def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]
)
def _describe_filament(entry: dict, nozzle_key: str) -> str:
"""One-line "PETG #000000 (left nozzle)" for an error message (#2771).
Shared by the required and loaded sides, which name their extruder
differently: a 3MF requirement carries ``nozzle_id``, a loaded tray carries
``extruder_id``. Both are MQTT extruder ids — 0 is the right/main nozzle,
1 the left/deputy — and both are absent on single-nozzle printers, where
naming a nozzle would be noise.
"""
parts = [(entry.get("type") or "filament").upper()]
if entry.get("color"):
parts.append(str(entry["color"]))
nozzle = entry.get(nozzle_key)
if nozzle == 0:
parts.append("(right nozzle)")
elif nozzle == 1:
parts.append("(left nozzle)")
return " ".join(parts)
def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str:
"""Explain that nothing loaded matches what the file needs (#2771).
Only ever built for a printer with no AMS, where the loaded list is short
enough to quote in full and there is no "load another spool and hit Resume"
recovery — the external spool holder is all there is, so the user needs to
be told which filament to put on it.
"""
want = ", ".join(_describe_filament(r, "nozzle_id") for r in required)
have = ", ".join(_describe_filament(f, "extruder_id") for f in loaded)
return (
f"No filament loaded on this printer matches the file. It needs {want}; "
f"the printer has {have} and no AMS. Load the required filament on the "
f"external spool holder, or send this job to a printer that has it."
)
class PrintScheduler:
"""Background scheduler that processes the print queue."""
@@ -663,6 +701,31 @@ class PrintScheduler:
if inflight_pid is not None:
busy_printers.add(inflight_pid)
# Printers held by a Home Assistant sensor interlock (#1148) — an
# enclosure door left open, say. The fixed-printer branch turns
# this into a waiting_reason the user can act on; the model-based
# branch hides these printers from the matcher so an "Any <model>"
# job runs on a sibling instead of queueing behind the held one.
#
# Deliberately NOT merged into busy_printers, even though that set
# already means "unavailable this pass". _check_auto_drying reads
# it as "is currently printing" and would put an idle-but-held
# printer down the mid-print drying path, which caps the drying
# temperature and skips the queue-only gating. A held printer is
# idle; it should dry exactly as it did before.
#
# Only sensors we actually read and found alerting appear here; see
# ha_sensor_manager.blocked_printers. A Home Assistant that is down
# holds nothing.
interlocked: dict[int, str] = {}
try:
interlocked = await ha_sensor_manager.blocked_printers(db)
except Exception as e:
# Never let the interlock stop the queue running. A broken
# lookup means no holds, not no dispatches.
logger.warning("Home Assistant interlock check failed: %s", e)
interlocked = {}
# Log skip reasons once per queue check (not per item)
skip_reasons: dict[str, int] = {}
@@ -722,6 +785,28 @@ class PrintScheduler:
continue
if item.printer_id:
# Held by a sensor interlock (#1148). Checked before the
# busy_printers test that would otherwise swallow it
# silently — "waiting for a printer" and "waiting for you
# to shut the enclosure" need to read differently, and only
# one of them is something the user can fix.
#
# The interlock is the only thing that writes a
# waiting_reason on this branch — the model-based branch
# nulls it at the moment it assigns a printer — so any
# reason still standing once the hold lifts is stale and is
# cleared here. Doing it at dispatch instead would leave a
# shut door reading "Waiting on Enclosure Door" for as long
# as the printer stayed busy with something else.
interlock_reason = interlocked.get(item.printer_id)
reason = f"Waiting on {interlock_reason}" if interlock_reason else None
if item.waiting_reason != reason:
item.waiting_reason = reason
await db.commit()
if interlock_reason:
skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
continue
# Specific printer assignment (existing behavior)
if item.printer_id in busy_printers:
continue
@@ -808,7 +893,10 @@ class PrintScheduler:
# (all -1). A stored all-[-1] mapping is a bug artifact — a
# frontend status-load race can persist [-1] (#2589) — and
# must be recomputed from live trays rather than trusted.
await self._ensure_ams_mapping(db, item.printer_id, item)
unmappable = await self._ensure_ams_mapping(db, item.printer_id, item)
if unmappable:
await self._fail_unmappable_item(db, item, item.printer_id, unmappable)
continue
# Filament-deficit pre-dispatch check (#1496). If the
# assigned spool can't satisfy any required slot grams,
@@ -917,7 +1005,10 @@ class PrintScheduler:
match_id, match_reason = await self._find_idle_printer_for_model(
db,
candidate.target_model,
busy_printers,
# Sensor-held printers are unavailable to the
# matcher but stay out of busy_printers itself
# (#1148) — see where `interlocked` is built.
busy_printers | interlocked.keys(),
effective_types,
item.target_location,
filament_overrides=filament_overrides,
@@ -1010,7 +1101,10 @@ class PrintScheduler:
# missing OR unresolved (all -1). Critical for model-based
# jobs where mapping wasn't computed upfront, and it also
# self-heals a bogus stored [-1] (#2589).
await self._ensure_ams_mapping(db, printer_id, item)
unmappable = await self._ensure_ams_mapping(db, printer_id, item)
if unmappable:
await self._fail_unmappable_item(db, item, printer_id, unmappable)
continue
# Filament-deficit pre-dispatch check (#1496).
if await self._block_on_filament_deficit(db, item):
@@ -1625,7 +1719,7 @@ class PrintScheduler:
# actually going to run so history and the ETA agree with reality.
item.print_time_seconds = variant.print_time_seconds
async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> None:
async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> str | None:
"""Ensure the queue item carries a usable AMS mapping before dispatch.
Recomputes from live printer status when the stored mapping is missing OR
@@ -1641,6 +1735,14 @@ class PrintScheduler:
external selection; the print command then keeps use_ams=True and the
firmware surfaces a clear AMS-mapping error instead of silently printing
to the empty external feed.
Returns an actionable message when that firmware error is the only
possible outcome — the matcher ran, matched nothing, and the printer has
no AMS to load a different spool into (#2771). The caller fails the item
on it instead of spending an upload on a print that cannot start.
Returns None everywhere else, including every case where we simply lack
the data to judge, so dispatch is only ever blocked on a positive
finding.
"""
stored_mapping: list | None = None
if item.ams_mapping:
@@ -1652,7 +1754,7 @@ class PrintScheduler:
# Already resolved (present and not all-unresolved) — keep as-is so a
# user's manual mapping is never overwritten.
if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
return
return None
computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -1664,7 +1766,9 @@ class PrintScheduler:
computed_mapping,
)
await db.commit()
elif _mapping_is_all_unresolved(stored_mapping):
return None
if _mapping_is_all_unresolved(stored_mapping):
logger.warning(
"Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
"from live status on printer %s; clearing it so dispatch does not treat it as external",
@@ -1675,6 +1779,105 @@ class PrintScheduler:
item.ams_mapping = None
await db.commit()
return await self._unmappable_without_ams_message(db, printer_id, item, computed_mapping)
async def _unmappable_without_ams_message(
self,
db: AsyncSession,
printer_id: int,
item: PrintQueueItem,
computed_mapping: list[int] | None,
) -> str | None:
"""Message for a mapping that resolved nothing on an AMS-less printer (#2771).
A print dispatched with no mapping goes out as ``use_ams: true`` with no
``ams_mapping`` and no ``ams_mapping2``, which the firmware rejects with
0700_8012 "Failed to get AMS mapping table" — after Bambuddy has already
uploaded several megabytes and burned its dispatch retries. With an AMS
attached that error is worth reaching: the user can load the right spool
and press Resume, so this returns None and today's behaviour stands. With
no AMS there is nothing to resume into — the external spool holder is the
whole inventory — so the useful answer is to say which filament is
missing and stop.
Fail-safe by construction, mirroring the nozzle-diameter guard (#1899):
every branch that lacks the evidence to be sure returns None.
"""
# None means the matcher never ran (no requirements parsed from the 3MF,
# or nothing loaded at all) rather than "ran and matched nothing". Those
# dispatch as they always have.
if not _mapping_is_all_unresolved(computed_mapping):
return None
status = printer_manager.get_status(printer_id)
if status is None:
return None
# "No AMS" has to be a fact the printer stated, not the absence of a
# statement. `raw_data["ams"]` is written only once an AMS push has been
# handled and is preserved across partial pushes thereafter, so a missing
# key means we have not heard yet — most likely a reconnect, where the
# trays of a fully loaded AMS would be invisible for a few seconds. An
# empty list is the positive report of a printer with no AMS.
ams_units = status.raw_data.get("ams")
if not isinstance(ams_units, list) or ams_units:
return None
required = await self._get_filament_requirements(db, item)
loaded = self._build_loaded_filaments(status)
if not required or not loaded:
# Both were non-empty moments ago or the matcher could not have run.
# If the picture changed under us, say nothing rather than fail an
# item on stale evidence.
return None
self._apply_filament_overrides(item, required)
return _unmatched_filament_message(required, loaded)
async def _fail_unmappable_item(
self, db: AsyncSession, item: PrintQueueItem, printer_id: int, message: str
) -> None:
"""Fail a queue item whose filament mapping cannot resolve (#2771).
This replaces a failure, not a success: without it the item is uploaded,
rejected by the firmware with 0700_8012, retried twice more and failed
anyway with "never started the print after N dispatch attempts". So this
applies on the model-based path too, even though it means an "Any <model>"
job stops at the first printer offered rather than trying its siblings —
deferring instead would need the check to move inside
``_find_printer_for_model``'s candidate loop, since un-assigning here just
re-assigns the same printer on the next tick.
"""
item.status = "failed"
item.error_message = message
item.completed_at = datetime.now(timezone.utc)
item.waiting_reason = None
await db.commit()
logger.warning(
"Queue item %s: no usable AMS mapping on printer %s — %s",
item.id,
printer_id,
message,
)
job_name = await self._get_job_name(db, item)
printer = await self._get_printer(db, printer_id)
await notification_service.on_queue_job_failed(
job_name=job_name,
printer_id=printer_id,
printer_name=printer.name if printer else "Unknown",
reason=message,
db=db,
)
try:
await ws_manager.send_queue_item_failed(
user_id=item.created_by_id,
queue_item_id=item.id,
printer_id=printer_id,
reason="filament_unmappable",
)
except Exception:
pass
async def _compute_ams_mapping_for_printer(
self, db: AsyncSession, printer_id: int, item: PrintQueueItem
) -> list[int] | None:
@@ -1727,37 +1930,7 @@ class PrintScheduler:
logger.debug("No filament requirements found for queue item %s", item.id)
return None
# Apply filament overrides if present
if item.filament_overrides:
try:
overrides = json.loads(item.filament_overrides)
override_map = {o["slot_id"]: o for o in overrides}
for req in filament_reqs:
if req["slot_id"] in override_map:
override = override_map[req["slot_id"]]
req["type"] = override["type"]
req["color"] = override["color"]
# A manual/preference override SWAPS the slot's filament, so the
# 3MF's original tray_info_idx now points at the old spool and must
# be cleared — matching then falls back to type+colour. A
# force_color_match override is not a swap: it carries the 3MF's
# intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
# it here too, letting the matcher pin the correct variant slot on a
# printer holding two same-colour spools of different variants (#2650).
# If that variant isn't loaded the matcher falls back to type+colour,
# so an eligible printer never fails to map.
req["tray_info_idx"] = (
override.get("tray_info_idx", "") if override.get("force_color_match") else ""
)
logger.debug(
"Queue item %s: Override slot %d -> %s %s",
item.id,
req["slot_id"],
override["type"],
override["color"],
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
self._apply_filament_overrides(item, filament_reqs)
# Build loaded filaments from printer status
loaded_filaments = self._build_loaded_filaments(status)
@@ -1791,6 +1964,47 @@ class PrintScheduler:
filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
)
def _apply_filament_overrides(self, item: PrintQueueItem, filament_reqs: list[dict]) -> None:
"""Rewrite ``filament_reqs`` in place with the item's per-slot overrides.
Extracted from ``_compute_ams_mapping_for_printer`` so the unmappable
diagnosis (#2771) describes the filament the matcher actually looked
for, not the one the 3MF was sliced with — naming the pre-override
filament in a user-facing error would send the user to load the wrong
spool.
"""
if not item.filament_overrides:
return
try:
overrides = json.loads(item.filament_overrides)
override_map = {o["slot_id"]: o for o in overrides}
for req in filament_reqs:
if req["slot_id"] in override_map:
override = override_map[req["slot_id"]]
req["type"] = override["type"]
req["color"] = override["color"]
# A manual/preference override SWAPS the slot's filament, so the
# 3MF's original tray_info_idx now points at the old spool and must
# be cleared — matching then falls back to type+colour. A
# force_color_match override is not a swap: it carries the 3MF's
# intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
# it here too, letting the matcher pin the correct variant slot on a
# printer holding two same-colour spools of different variants (#2650).
# If that variant isn't loaded the matcher falls back to type+colour,
# so an eligible printer never fails to map.
req["tray_info_idx"] = (
override.get("tray_info_idx", "") if override.get("force_color_match") else ""
)
logger.debug(
"Queue item %s: Override slot %d -> %s %s",
item.id,
req["slot_id"],
override["type"],
override["color"],
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
"""Build an AMS mapping directly from force-color overrides without a 3MF.
@@ -1864,6 +2078,38 @@ class PrintScheduler:
# Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
# Dual-nozzle detection, used below to route external spools to an
# extruder (#2771). Mirrors `buildLoadedFilaments` in the frontend,
# which was corrected for #1257 while this copy kept the old signal.
#
# `ams_extruder_map` is derived from AMS info bits, so a dual-nozzle
# printer with zero AMS units reports an empty map — and every external
# spool then got `extruder_id=None`, which the nozzle-aware filter in
# `_match_filaments_to_slots` rejects outright because `None` equals
# neither 0 nor 1. On an X2D feeding from external spools only that left
# nothing to match, the mapping came back all -1, and the print went out
# with `use_ams: true` and no mapping table at all — firmware 0700_8012,
# "Failed to get AMS mapping table".
#
# `nozzles` is always a two-entry list (the state seeds it with two empty
# NozzleInfo stubs), so its length proves nothing; only a populated
# diameter on the second entry means real hardware. The other two signals
# are fallbacks for firmware revisions that surface one but not the
# other: a populated `ams_extruder_map` is dual-nozzle by construction,
# and so is more than one `vt_tray` entry, since single-nozzle printers
# expose exactly one external feed.
nozzles = getattr(status, "nozzles", None) or []
vt_trays = status.raw_data.get("vt_tray") or []
is_dual_nozzle = bool(
(len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
or ams_extruder_map
# isinstance, because a dict here would count its ~30 keys as trays.
# bambu_mqtt normalises vt_tray to a list before it reaches raw_data,
# so this is unreachable — but the loop below would raise on a dict
# and that is the pre-existing behaviour to keep, not to paper over.
or (isinstance(vt_trays, list) and len(vt_trays) > 1)
)
# Parse AMS units from raw_data
ams_data = status.raw_data.get("ams", [])
for ams_unit in ams_data:
@@ -1900,7 +2146,7 @@ class PrintScheduler:
)
# Check external spool(s) (vt_tray is a list)
for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
for idx, vt in enumerate(vt_trays):
if vt.get("tray_type"):
color = self._normalize_color(vt.get("tray_color", ""))
tray_id = int(vt.get("id", 254))
@@ -1914,7 +2160,9 @@ class PrintScheduler:
"is_ht": False,
"is_external": True,
"global_tray_id": tray_id,
"extruder_id": (255 - tray_id) if ams_extruder_map else None,
# 254 = VIRTUAL_TRAY_DEPUTY_ID feeds extruder 1 (left),
# 255 = VIRTUAL_TRAY_MAIN_ID feeds extruder 0 (right).
"extruder_id": (255 - tray_id) if is_dual_nozzle else None,
"remain": vt.get("remain", -1),
}
)
@@ -0,0 +1,317 @@
"""Integration tests for the printer-bound Home Assistant sensor API (#1148)."""
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from backend.app.services.ha_sensor_manager import SensorReading, ha_sensor_manager
DOOR = {
"name": "Enclosure Door",
"entity_id": "binary_sensor.enclosure_door",
"kind": "binary",
"device_class": "door",
"alert_state": "on",
}
TEMP = {
"name": "Enclosure Temp",
"entity_id": "sensor.enclosure_temp",
"kind": "numeric",
"device_class": "temperature",
"unit": "°C",
}
@pytest.fixture(autouse=True)
def _no_live_ha():
"""Creating or editing a sensor reads it once; keep that off the network."""
with patch.object(ha_sensor_manager, "refresh_one", AsyncMock()):
yield
@pytest.fixture(autouse=True)
def _clean_cache():
yield
ha_sensor_manager._readings.clear()
ha_sensor_manager._last_alerting.clear()
class TestCrud:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bind_a_door_contact(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
assert response.status_code == 200
body = response.json()
assert body["entity_id"] == "binary_sensor.enclosure_door"
assert body["kind"] == "binary"
assert body["show_on_printer_card"] is True
# Display-only until the user opts in.
assert body["block_print"] is False
assert body["notify_on_alert"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):
"""Switches are smart plugs; this table is read-only sensors."""
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "kind": "binary"},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory):
"""block_print without an alert condition would never fire — that reads
as a broken setting, not as a no-op."""
printer = await printer_factory()
response = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
payload = {**DOOR, "printer_id": printer.id}
await async_client.post("/api/v1/ha-sensors/", json=payload)
response = await async_client.post("/api/v1/ha-sensors/", json=payload)
assert response.status_code == 400
assert "already bound" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_rejects_an_unknown_printer(self, async_client: AsyncClient):
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999})
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory):
"""The payload carries only block_print, so the coherence rule has to be
re-run against the merged row, not against the patch alone."""
printer = await printer_factory()
created = await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "alert_state": None},
)
sensor_id = created.json()["id"]
response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True})
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
sensor_id = created.json()["id"]
response = await async_client.patch(
f"/api/v1/ha-sensors/{sensor_id}",
json={"block_print": True, "notify_on_alert": True, "name": "Front Door"},
)
assert response.status_code == 200
assert response.json()["block_print"] is True
assert response.json()["name"] == "Front Door"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory):
"""Otherwise a later sensor reusing the id inherits this one's state."""
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
sensor_id = created.json()["id"]
ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True)
response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}")
assert response.status_code == 200
assert ha_sensor_manager.get_reading(sensor_id) is None
class TestReadings:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_serves_the_cached_reading(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
created = await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "alert_above": 35},
)
sensor_id = created.json()["id"]
ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert response.status_code == 200
reading = response.json()[0]
assert reading["value"] == 41.2
assert reading["alerting"] is True
assert reading["unit"] == "°C"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory):
"""Right after a restart the card should still list the sensor, greyed
out — not drop it and reflow the layout."""
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert len(response.json()) == 1
assert response.json()[0]["reachable"] is False
assert response.json()[0]["alerting"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory):
"""An interlock the user does not want cluttering the card still works."""
printer = await printer_factory()
await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False},
)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert response.json() == []
@pytest.mark.asyncio
@pytest.mark.integration
async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
await async_client.post(
"/api/v1/ha-sensors/",
json={**TEMP, "printer_id": printer.id, "sort_order": 2},
)
await async_client.post(
"/api/v1/ha-sensors/",
json={**DOOR, "printer_id": printer.id, "sort_order": 1},
)
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory):
one = await printer_factory()
two = await printer_factory(serial_number="OTHER123", name="Second")
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id})
response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{two.id}/readings")
assert response.json() == []
class TestEntityPicker:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
response = await async_client.get("/api/v1/ha-sensors/entities")
assert response.status_code == 400
assert "Home Assistant not configured" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
"""Route ordering regression: /entities must not hit /{sensor_id}."""
response = await async_client.get("/api/v1/ha-sensors/entities")
assert response.status_code != 404
class TestCascadeAndUniqueness:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id})
response = await async_client.patch(
f"/api/v1/ha-sensors/{second.json()['id']}",
json={"entity_id": DOOR["entity_id"], "kind": "binary"},
)
assert response.status_code == 400
assert "already bound" in response.json()["detail"]
@pytest.mark.asyncio
@pytest.mark.integration
async def test_patch_to_the_same_entity_is_not_a_clash_with_itself(
self, async_client: AsyncClient, printer_factory
):
printer = await printer_factory()
created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
response = await async_client.patch(
f"/api/v1/ha-sensors/{created.json()['id']}",
json={"entity_id": DOOR["entity_id"], "name": "Front Door"},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory):
"""The relationship cascades, so no orphan row is left holding a
printer_id that no longer resolves."""
printer = await printer_factory()
await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
deleted = await async_client.delete(f"/api/v1/printers/{printer.id}")
assert deleted.status_code == 200
listed = await async_client.get("/api/v1/ha-sensors/")
assert listed.json() == []
class TestSaveSurvivesHomeAssistant:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory):
"""The row is committed before the read. Reporting a failure for work
that succeeded would send the user into a retry that 400s on the
duplicate they just created."""
printer = await printer_factory()
with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
assert response.status_code == 200
listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
assert len(listed.json()) == 1
+86 -3
View File
@@ -4044,13 +4044,13 @@ class TestSendDryingCommand:
def test_start_caches_target_for_badge(self, mqtt_client):
"""mode=1 send populates _drying_targets so the badge can render it."""
mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65}
assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65, "duration_hours": 12}
def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
"""A second start on the same AMS replaces the cached target."""
mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70}
assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70, "duration_hours": 6}
def test_stop_clears_target(self, mqtt_client):
"""mode=0 send drops the cache so the badge stops showing the target."""
@@ -4065,7 +4065,7 @@ class TestSendDryingCommand:
mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
assert 0 not in mqtt_client._drying_targets
assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80}
assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80, "duration_hours": 6}
class TestStartPrintAmsMapping:
@@ -6120,6 +6120,89 @@ class TestDryingCompleteCallback:
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
assert mqtt_client._drying_events == [0]
def test_early_end_logs_firmware_reason_codes(self, mqtt_client, caplog):
"""#2770 — a 12-hour cycle the firmware abandoned 20 minutes in logged
only 'drying complete', so the report carried no evidence of why. An
early end now names the shortfall and the reason fields we already
parse: phase, sub-phase, cannot-dry codes and live HMS."""
from backend.app.services.bambu_mqtt import HMSError
mqtt_client.state.hms_errors = [
HMSError(code="0x2000003", attr=0x07008000, module=7, severity=2, full_code="0700800002000003")
]
mqtt_client._client = MagicMock()
mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
mqtt_client._handle_ams_data(
{"ams": [{"id": "0", "dry_time": 700, "info": "10002123", "dry_sf_reason": [1], "tray": []}]}
)
with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
mqtt_client._handle_ams_data(
{"ams": [{"id": "0", "dry_time": 0, "info": "10002103", "dry_sf_reason": [1], "tray": []}]}
)
assert mqtt_client._drying_events == [0]
message = "\n".join(r.getMessage() for r in caplog.records)
assert "drying ended early" in message
# The shortfall, against the duration we asked the firmware for.
assert "700 of 720 minutes" in message
# dry_status 0 (Off) and dry_sub_status 0 from info hex 10002103.
assert "dry_status=0" in message
assert "dry_sub_status=0" in message
# InsufficientPower, and the AMS heater-fan HMS that goes with it.
assert "dry_sf_reason=[1]" in message
assert "0700800002000003" in message
def test_early_end_without_a_cached_target_still_logs(self, mqtt_client, caplog):
"""A cycle Bambuddy did not start — from the printer's screen, from
Studio, or from before a restart — has no cached duration to compare
against. The remaining time alone still proves it was cut short, so the
reason codes must be logged rather than withheld for lack of a target."""
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 480, "tray": []}]})
with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
message = "\n".join(r.getMessage() for r in caplog.records)
assert "drying ended early" in message
assert "480 of ? minutes" in message
assert "hms=none" in message
def test_stop_we_sent_is_not_blamed_on_the_firmware(self, mqtt_client, caplog):
"""A stop Bambuddy sends — print takes priority, or the user's Stop
button — also ends the cycle far short of its duration, which on the
telemetry alone looks exactly like the firmware abandoning it. It must
be named as ours rather than reported as an unexplained early end."""
mqtt_client._client = MagicMock()
mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
message = "\n".join(r.getMessage() for r in caplog.records)
assert "drying stopped by Bambuddy" in message
assert "ended early" not in message
# And the attribution is consumed, so a later firmware-ended cycle on
# the same unit is not credited to a stop we sent hours earlier.
mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
caplog.clear()
with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
assert "drying ended early" in "\n".join(r.getMessage() for r in caplog.records)
def test_cycle_that_runs_to_term_keeps_the_plain_completion_log(self, mqtt_client, caplog):
"""The countdown of a cycle that finishes normally is all but exhausted
when it drops to 0. Nothing needs explaining, so it keeps the one-line
message it has always had — the early-end diagnostics must not become
noise on every completed dry."""
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
message = "\n".join(r.getMessage() for r in caplog.records)
assert "drying complete (dry_time 1 → 0)" in message
assert "ended early" not in message
class TestPrintRunningObservedCallback:
"""#1485 follow-up: on_print_running_observed fires the FIRST time we
@@ -919,6 +919,64 @@ class TestHomeAssistantProvider:
# field is JSON rather than key=value lines.
assert payload["data"]["ttl"] == 0
@pytest.mark.asyncio
async def test_send_homeassistant_custom_data_keeps_nested_structures(self, service):
"""Nested objects and lists reach the notify service unaltered (#1441).
The three tests around this one all use flat scalars, which is also all
the placeholder and the wiki showed — so a user asking whether action
buttons work had nothing telling them the field is a verbatim
pass-through rather than a key/value list. ``actions`` is the case they
asked about: a list of objects, the shape an HA automation writes under
``data.actions``. Nothing between the textarea and the POST inspects the
parsed value beyond "is it an object", so this asserts the whole
structure rather than a key at a time.
"""
mock_response = MagicMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_db = AsyncMock()
with (
patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
patch(
"backend.app.api.routes.settings.get_homeassistant_settings",
new_callable=AsyncMock,
) as mock_ha_settings,
):
mock_get_client.return_value = mock_client
mock_ha_settings.return_value = {
"ha_url": "http://ha.local:8123",
"ha_token": "test-token-123",
"ha_enabled": True,
}
actions = [
{"action": "SNOOZE_PRINT_FINISHED", "title": "Snooze 20 min"},
{"action": "BED_COOL_NOTIFY_ON", "title": "Notify on Bed Cool"},
]
config = {
"service": "notify.mobile_app_myphone",
"data": json.dumps({"ttl": 0, "priority": "high", "group": "3D Printer", "actions": actions}),
}
success, _ = await service._send_homeassistant(config, "Print Finished", "Print is finished", db=mock_db)
assert success is True
payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
assert payload["data"] == {
"ttl": 0,
"priority": "high",
"group": "3D Printer",
"actions": actions,
}
# Spelled out separately: a flattening or scalar-only filter would
# still leave the three sibling keys correct, so the equality above
# is not on its own evidence that the list survived.
assert payload["data"]["actions"] == actions
@pytest.mark.asyncio
async def test_send_homeassistant_without_data_omits_key(self, service):
"""Without configured data the payload carries no "data" key — the
@@ -0,0 +1,290 @@
"""The RTSP camera paths must not become a request generator for arbitrary hosts.
`_sanitize_camera_url` is the SSRF boundary for user-configured camera URLs. It
was applied to the MJPEG and snapshot paths but not to the two RTSP ones, which
handed the URL to `ffmpeg -i` unchecked — and ffmpeg's `-i` speaks http, tcp,
file and everything else it was built with, so `camera_type=rtsp` was a way to
name any destination and any protocol.
Wiring the guard in is only half of it. The guard rebuilt URLs from
`parsed.hostname`, which drops credentials and unbrackets IPv6 literals, and it
recognised loopback by comparing against four spellings of it. So these tests
pin three things at once: the RTSP paths refuse what they should, the guard
recognises a destination however it is written, and a real camera — which
usually means an authenticated one — still works.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.external_camera import (
_blocked_host_reason,
_capture_rtsp_frame,
_safe_usb_device_path,
_sanitize_camera_url,
_stream_rtsp,
)
RTSP_SCHEMES = ("rtsp", "rtsps")
HTTP_SCHEMES = ("http", "https")
class TestTheHostsWeRefuse:
"""Loopback, the unspecified address and link-local, however they are spelled."""
@pytest.mark.parametrize(
"host",
[
"127.0.0.1",
"127.0.0.2", # the whole 127/8 range, not just .1
"127.1", # short form
"2130706433", # decimal
"0177.0.0.1", # octal
"0x7f.0.0.1", # hex
"[::1]",
"[::ffff:127.0.0.1]", # loopback wearing an IPv6 spelling
"localhost",
"sub.localhost",
],
)
def test_loopback_is_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
@pytest.mark.parametrize("host", ["0.0.0.0", "[::]"]) # nosec B104
def test_the_unspecified_address_is_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
@pytest.mark.parametrize(
"host",
[
"169.254.169.254", # AWS/GCP/Azure metadata
"169.254.1.1", # the rest of the range, not just the metadata IP
"[fe80::1]",
"metadata.google.internal",
"metadata.google",
],
)
def test_link_local_and_metadata_are_refused(self, host):
assert _sanitize_camera_url(f"rtsp://{host}/live", RTSP_SCHEMES) is None
def test_the_reason_is_reported_for_logging(self):
assert _blocked_host_reason("2130706433") == "loopback"
assert _blocked_host_reason("169.254.169.254") is not None
assert _blocked_host_reason("192.168.1.50") is None
class TestTheCamerasWeAllow:
"""LAN is allowed on purpose — that is where cameras are."""
@pytest.mark.parametrize(
"url",
[
"rtsp://192.168.1.50:554/live",
"rtsp://10.0.0.5/stream1",
"rtsp://172.16.4.9:8554/cam",
"rtsp://[fd00::1]:554/live", # unique-local IPv6
"rtsp://cam.lan/live",
"rtsps://camera.example.com:322/stream",
],
)
def test_a_camera_url_survives(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is not None
def test_a_hostname_is_not_resolved(self):
"""A name that would resolve to loopback still passes.
Not an oversight: aiohttp and ffmpeg resolve independently afterwards,
so a lookup here decides nothing (DNS rebinding) while costing a DNS
round trip on every capture. Pinned so the omission stays deliberate.
"""
assert _sanitize_camera_url("rtsp://localtest.me/live", RTSP_SCHEMES) is not None
class TestWhatTheGuardMustNotDestroy:
"""Most RTSP cameras carry their login in the URL. Stripping it would turn
every one of them into an authentication failure — a worse outage than the
hole being closed."""
def test_credentials_survive(self):
url = "rtsp://admin:hunter2@192.168.1.50:554/live"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
def test_percent_encoded_credentials_survive_byte_for_byte(self):
"""urlparse's .username/.password are already decoded, so rebuilding
from them would corrupt any password containing an @ or a :."""
url = "rtsp://ad%40min:p%3Ass%40word@192.168.1.50:554/live"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
def test_an_ipv6_literal_keeps_its_brackets(self):
"""Without them the result is not a URL any client can parse."""
assert _sanitize_camera_url("rtsp://[fd00::1]:554/live", RTSP_SCHEMES) == "rtsp://[fd00::1]:554/live"
def test_http_cameras_keep_their_basic_auth_too(self):
url = "http://admin:hunter2@192.168.1.50/stream.mjpg"
assert _sanitize_camera_url(url, HTTP_SCHEMES) == url
def test_port_query_and_fragment_survive(self):
url = "rtsp://192.168.1.50:8554/live?channel=2&subtype=1#frag"
assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
class TestSchemeAllowlist:
"""What keeps an ffmpeg input a camera fetch rather than a fetch."""
@pytest.mark.parametrize(
"url",
[
"http://192.168.1.50:8080/internal",
"https://192.168.1.50/internal",
"tcp://192.168.1.50:22",
"file:///etc/passwd",
"concat:/etc/passwd",
"udp://192.168.1.50:1234",
"ftp://192.168.1.50/x",
],
)
def test_only_rtsp_reaches_the_rtsp_paths(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
def test_rtsp_does_not_reach_the_http_paths(self):
assert _sanitize_camera_url("rtsp://192.168.1.50/live", HTTP_SCHEMES) is None
@pytest.mark.parametrize("url", ["", "not a url", "rtsp://", "://192.168.1.50/x"])
def test_malformed_input_is_refused(self, url):
assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
def _fake_ffmpeg():
return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
def _spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
"""Stand in for the ffmpeg subprocess, recording the argv it was handed.
The streaming path reads until EOF, so stdout.read returns b"" and the
generator finishes immediately — these tests are about whether ffmpeg was
launched and with what, not about frame extraction.
"""
process = MagicMock()
process.returncode = returncode
process.communicate = AsyncMock(return_value=(stdout, b""))
process.stdout.read = AsyncMock(return_value=b"")
process.stderr.read = AsyncMock(return_value=b"")
process.wait = AsyncMock(return_value=returncode)
process.kill = MagicMock()
process.terminate = MagicMock()
return patch(
"backend.app.services.external_camera.asyncio.create_subprocess_exec",
new=AsyncMock(return_value=process),
)
class TestRtspCaptureRefusesUnsafeUrls:
"""`_capture_rtsp_frame` — the one-shot path behind the test-connection
endpoint, which takes url and camera_type straight off the query string."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/internal-service", # the reported PoC
"http://192.168.1.100:8080/any-image.jpg",
"file:///etc/passwd",
"rtsp://127.0.0.1:554/live",
"rtsp://2130706433:554/live",
"rtsp://169.254.169.254/live",
],
)
async def test_no_process_is_spawned(self, url):
with _fake_ffmpeg(), _spawn_spy() as spawn:
assert await _capture_rtsp_frame(url, timeout=5) is None
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_real_camera_still_captures(self):
with _fake_ffmpeg(), _spawn_spy() as spawn:
frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
assert frame is not None
cmd = spawn.await_args.args
assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd, (
"the camera's credentials must reach ffmpeg or every authenticated camera breaks"
)
@pytest.mark.asyncio
async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
"""Belt and braces behind the scheme check: a stream that references
something outside itself must not be able to pull it in."""
with _fake_ffmpeg(), _spawn_spy() as spawn:
await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
cmd = spawn.await_args.args
whitelist = cmd[cmd.index("-protocol_whitelist") + 1].split(",")
assert "rtsp" in whitelist
assert "file" not in whitelist
assert "http" not in whitelist
class TestRtspStreamRefusesUnsafeUrls:
"""`_stream_rtsp` — the live-view path, and the one the report missed."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"http://127.0.0.1:8080/internal-service",
"rtsp://127.0.0.1:554/live",
"rtsp://[::ffff:127.0.0.1]:554/live",
"file:///etc/passwd",
],
)
async def test_no_process_is_spawned(self, url):
with _fake_ffmpeg(), _spawn_spy() as spawn:
frames = [frame async for frame in _stream_rtsp(url, fps=5)]
assert frames == []
spawn.assert_not_awaited()
@pytest.mark.asyncio
async def test_a_real_camera_still_reaches_ffmpeg(self):
with _fake_ffmpeg(), _spawn_spy(returncode=None) as spawn:
[frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
spawn.assert_awaited_once()
cmd = spawn.await_args.args
assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd
assert "-protocol_whitelist" in cmd
class TestUsbDevicePaths:
"""The USB paths take a device path from the same request field, and the
streaming one used to check only that it started with /dev/video."""
@pytest.mark.parametrize(
"device",
[
"/dev/video/../../etc/passwd",
"/dev/videos/../../etc/shadow",
"/dev/video0; rm -rf /",
"/etc/passwd",
"/dev/video100", # three digits is not a device number
"",
],
)
def test_a_path_that_is_not_a_device_node_is_refused(self, device):
assert _safe_usb_device_path(device) is None
def test_a_missing_device_is_refused(self):
"""Existence is part of the check — ffmpeg must never be pointed at a
path just because it is shaped like one."""
with patch("backend.app.services.external_camera.Path") as path_cls:
path_cls.return_value.exists.return_value = False
assert _safe_usb_device_path("/dev/video0") is None
def test_the_path_is_rebuilt_from_the_device_number(self):
with patch("backend.app.services.external_camera.Path") as path_cls:
path_cls.return_value.exists.return_value = True
path_cls.return_value.__str__.return_value = "/dev/video7"
assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
path_cls.assert_called_once_with("/dev/video7")
@@ -0,0 +1,281 @@
"""Unit tests for Home Assistant sensors bound to a printer (#1148, #448).
The alert rules decide three separate things — the pill colour on the card, a
notification, and whether the queue holds — so they are tested directly rather
than through any one of those consumers.
The recurring theme is that "we could not read it" must never be mistaken for
a reading. A door contact whose integration has dropped out reports
"unavailable", not "closed", and treating that as closed would let a print
start into an open enclosure; treating it as *open* would strand the queue.
Neither: it is not a reading at all.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from backend.app.services.ha_sensor_manager import (
HASensorManager,
SensorReading,
describe_state,
evaluate,
)
def _sensor(**overrides):
"""A sensor row as the poller sees it, without touching the DB."""
base = {
"id": 1,
"printer_id": 4,
"name": "Enclosure Door",
"entity_id": "binary_sensor.enclosure_door",
"kind": "binary",
"device_class": "door",
"unit": None,
"alert_state": "on",
"alert_above": None,
"alert_below": None,
"block_print": False,
"notify_on_alert": False,
"last_state": None,
}
base.update(overrides)
return SimpleNamespace(**base)
def _numeric(**overrides):
base = {
"entity_id": "sensor.enclosure_temp",
"kind": "numeric",
"device_class": "temperature",
"unit": "\u00b0C",
"alert_state": None,
"name": "Enclosure Temp",
}
base.update(overrides)
return _sensor(**base)
class TestEvaluateBinary:
def test_alerts_in_the_configured_state(self):
reading = evaluate(_sensor(), {"state": "on"})
assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True)
def test_quiet_in_the_other_state(self):
assert evaluate(_sensor(), {"state": "off"}).alerting is False
def test_alert_state_off_inverts_the_rule(self):
"""A "fan running" contact alarms when it stops, not when it starts."""
sensor = _sensor(alert_state="off", name="Exhaust Fan")
assert evaluate(sensor, {"state": "off"}).alerting is True
assert evaluate(sensor, {"state": "on"}).alerting is False
def test_no_alert_state_never_alerts(self):
"""Display-only sensors are the default — they just show a state."""
sensor = _sensor(alert_state=None)
assert evaluate(sensor, {"state": "on"}).alerting is False
assert evaluate(sensor, {"state": "on"}).reachable is True
def test_state_is_normalised_to_lower_case(self):
"""Some integrations report "ON"; the alert rule stores "on"."""
assert evaluate(_sensor(), {"state": "ON"}).state == "on"
assert evaluate(_sensor(), {"state": "ON"}).alerting is True
class TestEvaluateNumeric:
def test_above_threshold_alerts(self):
assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True
def test_below_threshold_alerts(self):
assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True
def test_inside_the_band_is_quiet(self):
reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"})
assert reading.alerting is False
assert reading.value == 22.5
def test_exactly_on_the_threshold_is_not_an_alert(self):
"""Strict comparison, so a 35 °C limit does not alarm at exactly 35."""
assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False
def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self):
"""Reachable, but no value to compare — so no verdict either way."""
reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"})
assert reading.reachable is True
assert reading.value is None
assert reading.alerting is False
class TestUnreadable:
@pytest.mark.parametrize("state", ["unavailable", "unknown", None])
def test_ha_non_states_are_not_readings(self, state):
reading = evaluate(_sensor(), {"state": state})
assert reading.reachable is False
assert reading.alerting is False
assert reading.state is None
def test_a_failed_fetch_is_not_a_reading(self):
"""fetch_states maps an entity it could not read to None."""
reading = evaluate(_sensor(), None)
assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False)
class TestDescribeState:
def test_binary_uses_the_raw_state(self):
assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on"
def test_numeric_carries_its_unit(self):
sensor = _numeric()
assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C"
def test_numeric_without_a_unit_is_bare(self):
sensor = _numeric(unit=None)
assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7"
class TestBlockedPrinters:
"""The interlock only ever reports a positive, current finding."""
def _manager_with(self, sensors, readings):
manager = HASensorManager()
manager._readings = readings
db = AsyncMock()
db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors))
return manager, db
@pytest.mark.asyncio
async def test_reports_an_alerting_blocking_sensor(self):
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)})
assert await manager.blocked_printers(db) == {4: "Enclosure Door"}
@pytest.mark.asyncio
async def test_silent_when_not_alerting(self):
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_silent_when_home_assistant_is_unreachable(self):
"""The queue must keep running when HA is down, not seize up."""
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_silent_before_the_first_poll(self):
"""A cold cache is not evidence the door is open."""
sensor = _sensor(block_print=True)
manager, db = self._manager_with([sensor], {})
assert await manager.blocked_printers(db) == {}
@pytest.mark.asyncio
async def test_names_every_blocking_sensor_on_a_printer(self):
sensors = [
_sensor(id=1, block_print=True, name="Front Door"),
_sensor(id=2, block_print=True, name="Side Panel"),
]
manager, db = self._manager_with(
sensors,
{
1: SensorReading("on", None, True, True),
2: SensorReading("on", None, True, True),
},
)
assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"}
class TestNotificationEdge:
"""Alerts fire on the transition into the alert state, not while it lasts."""
async def _apply(self, manager, sensor, states, notify):
db = AsyncMock()
db.get.return_value = SimpleNamespace(name="X1C-1")
with patch("backend.app.services.notification_service.notification_service", notify):
await manager._apply(db, [sensor], states)
@pytest.mark.asyncio
async def test_fires_once_on_the_way_in(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
# First poll seeds the cache; a door already open at startup has not
# just been opened.
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1
# Still open on the next pass — no second alert.
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1
@pytest.mark.asyncio
async def test_silent_on_the_first_poll_after_a_restart(self):
"""Cold cache. Re-announcing every pre-existing alert on every restart
is how users learn to ignore the alert."""
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
assert manager.get_reading(sensor.id).alerting is True
@pytest.mark.asyncio
async def test_silent_when_the_sensor_opts_out(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=False)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 0
@pytest.mark.asyncio
async def test_re_arms_after_the_alert_clears(self):
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
for state in ("off", "on", "off", "on"):
await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify)
assert notify.on_ha_sensor_alert.await_count == 2
@pytest.mark.asyncio
async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
"""on -> unavailable -> on is one continuous alert, not two.
Without this, a flaky Zigbee contact would notify on every reconnect.
"""
manager = HASensorManager()
sensor = _sensor(notify_on_alert=True)
notify = AsyncMock()
await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
assert notify.on_ha_sensor_alert.await_count == 1
@@ -299,7 +299,10 @@ async def _run_check_queue(ctx, scheduler, finder, waiting_notification=None):
patch.object(scheduler, "_check_auto_drying", AsyncMock()),
# Selection is what's under test — keep AMS recomputation and the
# filament-deficit probe out of the way, and never actually dispatch.
patch.object(scheduler, "_ensure_ams_mapping", AsyncMock()),
# None is the mapping-resolved answer; a bare AsyncMock returns a truthy
# sentinel, which the unmappable guard (#2771) reads as "this job can
# never print" and fails the item on.
patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
patch.object(scheduler, "_launch_uploads", MagicMock()),
]
@@ -0,0 +1,259 @@
"""Regression tests for external-spool nozzle routing on AMS-less printers (#2771).
A fleet of X2Ds with no AMS, printing from external spools, could not be sent a
job with "Any X2D": the print uploaded, then the firmware rejected it with
0700_8012 "Failed to get AMS mapping table" and the item failed after three
dispatch attempts. Sending the same file to a named printer worked, because that
path carries a mapping the *frontend* resolved and the scheduler's matcher never
runs.
Cause: ``_build_loaded_filaments`` derived dual-nozzle status from
``ams_extruder_map``, which is built from AMS info bits — a dual-nozzle printer
with zero AMS units reports an empty map. Every external spool then got
``extruder_id=None``, and the nozzle-aware hard filter in
``_match_filaments_to_slots`` rejected it because ``None`` equals neither 0 nor
1. Nothing matched, the mapping came back all -1 and was cleared to None, and the
print command went out as ``use_ams: true`` with no mapping table at all.
This is the backend half of #1257, which fixed the identical logic in
``useFilamentMapping.ts`` and left this copy behind; the first two tests below
mirror its frontend regression tests.
The second half covers the guard that keeps a genuinely unmappable job from
being uploaded at all, since without an AMS there is no "load another spool and
press Resume" recovery for the firmware error to lead to.
"""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import (
PrintScheduler,
_unmatched_filament_message,
)
# Two external feeds, as an X2D/H2D reports them: 254 is Ext-L (deputy/left,
# extruder 1) and 255 is Ext-R (main/right, extruder 0).
DUAL_EXTERNAL = [
{"id": "254", "tray_type": "PETG", "tray_color": "000000FF", "tray_info_idx": "GFG00"},
{"id": "255", "tray_type": "PLA", "tray_color": "FFFFFFFF", "tray_info_idx": "GFA00"},
]
REAL_NOZZLES = [
SimpleNamespace(nozzle_diameter="0.4"),
SimpleNamespace(nozzle_diameter="0.4"),
]
# The state seeds `nozzles` with two empty NozzleInfo stubs even on single-nozzle
# printers, so the second entry's presence proves nothing — only a diameter does.
STUB_NOZZLES = [
SimpleNamespace(nozzle_diameter="0.4"),
SimpleNamespace(nozzle_diameter=""),
]
def _status(raw_data, nozzles=None):
return SimpleNamespace(raw_data=raw_data, nozzles=nozzles)
@pytest.fixture
def scheduler():
return PrintScheduler()
class TestExternalSpoolExtruderRouting:
"""``_build_loaded_filaments`` must route external spools without an AMS."""
def test_dual_nozzle_without_ams_routes_both_external_feeds(self, scheduler):
"""The X2D case from the report: no AMS, so ams_extruder_map is empty."""
loaded = scheduler._build_loaded_filaments(
_status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
)
by_tray = {f["global_tray_id"]: f for f in loaded}
assert by_tray[254]["extruder_id"] == 1 # Ext-L -> left
assert by_tray[255]["extruder_id"] == 0 # Ext-R -> right
def test_single_nozzle_stub_does_not_fabricate_an_extruder(self, scheduler):
"""A P1S/A1/X1C must keep extruder_id=None, matching pre-fix behaviour.
Sibling regression to the fix: `nozzles` always has two entries, so
inferring dual-nozzle from its length would hand every single-nozzle
printer's external spool a nozzle it does not have.
"""
loaded = scheduler._build_loaded_filaments(
_status(
{"ams": [], "ams_extruder_map": {}, "vt_tray": [DUAL_EXTERNAL[0]]},
STUB_NOZZLES,
)
)
assert len(loaded) == 1
assert loaded[0]["extruder_id"] is None
def test_two_external_feeds_alone_imply_dual_nozzle(self, scheduler):
"""Fallback signal: only dual-nozzle hardware exposes two external feeds.
Kept for firmware revisions that report the feeds but not the nozzle
diameters — here `nozzles` is absent entirely.
"""
loaded = scheduler._build_loaded_filaments(
_status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL})
)
assert {f["extruder_id"] for f in loaded} == {0, 1}
def test_populated_ams_extruder_map_still_implies_dual_nozzle(self, scheduler):
"""The original signal keeps working when there IS an AMS."""
loaded = scheduler._build_loaded_filaments(
_status(
{"ams": [], "ams_extruder_map": {"0": 1}, "vt_tray": [DUAL_EXTERNAL[0]]},
STUB_NOZZLES,
)
)
assert loaded[0]["extruder_id"] == 1
def test_mapping_resolves_for_the_nozzle_the_spool_feeds(self, scheduler):
"""End to end: the matcher now finds the external spool, as it did for
the working named-printer dispatch (which sent ams_mapping [254])."""
loaded = scheduler._build_loaded_filaments(
_status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
)
req = {"slot_id": 1, "type": "PETG", "color": "#000000", "tray_info_idx": "GFG00"}
assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 1}], loaded) == [254]
# Nothing PETG on the right nozzle — still correctly unmatched.
assert scheduler._match_filaments_to_slots([{**req, "nozzle_id": 0}], loaded) == [-1]
class TestUnmatchedFilamentMessage:
"""The message has to name the filament and, on dual-nozzle, the nozzle."""
def test_names_type_colour_and_nozzle(self):
message = _unmatched_filament_message(
[{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}],
[{"type": "PETG", "color": "#000000", "extruder_id": 1}],
)
assert "PETG #000000 (right nozzle)" in message
assert "PETG #000000 (left nozzle)" in message
def test_omits_nozzle_on_single_nozzle_printers(self):
message = _unmatched_filament_message(
[{"slot_id": 1, "type": "ABS", "color": "#FF0000"}],
[{"type": "PLA", "color": "#000000"}],
)
assert "ABS #FF0000" in message
assert "nozzle" not in message
class TestUnmappableWithoutAmsGuard:
"""``_ensure_ams_mapping`` reports only a positive, unrecoverable finding."""
def _item(self, ams_mapping=None):
item = MagicMock()
item.id = 22
item.printer_id = 4
item.ams_mapping = ams_mapping
item.filament_overrides = None
return item
async def _ensure(self, scheduler, computed, status):
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=computed)
scheduler._get_filament_requirements = AsyncMock(
return_value=[{"slot_id": 1, "type": "PETG", "color": "#000000", "nozzle_id": 0}]
)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = status
return await scheduler._ensure_ams_mapping(AsyncMock(), 4, self._item())
@pytest.mark.asyncio
async def test_reports_when_nothing_matches_and_there_is_no_ams(self, scheduler):
status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
message = await self._ensure(scheduler, [-1], status)
assert message is not None
assert "no AMS" in message
@pytest.mark.asyncio
async def test_silent_when_an_ams_is_attached(self, scheduler):
"""With an AMS the user can load a spool and press Resume, so the
firmware's own error is worth reaching — behaviour is unchanged."""
status = _status(
{
"ams": [{"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]}],
"ams_extruder_map": {},
"vt_tray": DUAL_EXTERNAL,
},
REAL_NOZZLES,
)
assert await self._ensure(scheduler, [-1], status) is None
@pytest.mark.asyncio
async def test_silent_when_the_ams_field_has_not_arrived_yet(self, scheduler):
"""Absence of an AMS report is not a report of no AMS.
`raw_data["ams"]` appears only once an AMS push has been handled, so a
missing key means a reconnect or a cold start — where a fully loaded
AMS is briefly invisible and everything would look unmappable.
"""
status = _status({"ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
assert await self._ensure(scheduler, [-1], status) is None
@pytest.mark.asyncio
async def test_silent_when_the_matcher_never_ran(self, scheduler):
"""A None mapping means no requirements parsed or nothing loaded — not
evidence of a mismatch. Fail-safe: dispatch as before."""
status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
assert await self._ensure(scheduler, None, status) is None
@pytest.mark.asyncio
async def test_silent_when_the_mapping_resolves(self, scheduler):
status = _status({"ams": [], "ams_extruder_map": {}, "vt_tray": DUAL_EXTERNAL}, REAL_NOZZLES)
item = self._item()
scheduler._compute_ams_mapping_for_printer = AsyncMock(return_value=[254])
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = status
assert await scheduler._ensure_ams_mapping(AsyncMock(), 4, item) is None
assert json.loads(item.ams_mapping) == [254]
@pytest.mark.asyncio
async def test_silent_when_the_printer_status_is_gone(self, scheduler):
assert await self._ensure(scheduler, [-1], None) is None
class TestFailUnmappableItem:
"""The guard fails the item instead of spending an upload on it."""
@pytest.mark.asyncio
async def test_marks_failed_with_the_message(self, scheduler):
db = AsyncMock()
item = MagicMock()
item.id = 22
item.created_by_id = 1
with (
patch("backend.app.services.print_scheduler.notification_service") as notify,
patch("backend.app.services.print_scheduler.ws_manager"),
):
notify.on_queue_job_failed = AsyncMock()
scheduler._get_job_name = AsyncMock(return_value="Fidget")
scheduler._get_printer = AsyncMock(return_value=SimpleNamespace(name="X2D-1"))
await scheduler._fail_unmappable_item(db, item, 4, "needs PETG")
assert item.status == "failed"
assert item.error_message == "needs PETG"
assert item.completed_at is not None
db.commit.assert_awaited()
notify.on_queue_job_failed.assert_awaited_once()
@@ -0,0 +1,306 @@
"""The Home Assistant sensor interlock, seen from the scheduler (#1148).
The feature exists because the reporter wanted to know his enclosure was shut
before starting a print remotely. Displaying the door state only helps if he
looks; the interlock is what makes it act on its own.
It is a *hold*, never a failure: the item stays pending and dispatches by
itself once the door closes. And it holds only on a positive, freshly read
finding — a Home Assistant that is unreachable holds nothing, because a queue
that stops whenever an unrelated service goes down is worse than the problem.
"""
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import backend.app.models # noqa: F401 - populate Base.metadata
from backend.app.core.database import Base
from backend.app.models.library import LibraryFile
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.printer import Printer
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
async def queue_db():
"""Two X1Cs, so a model-based job has somewhere else to go."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
async with session_maker() as db:
db.add_all(
[
Printer(
id=1,
name="X1C-1",
serial_number="X1C0001",
ip_address="10.0.0.1",
access_code="x",
model="X1C",
is_active=True,
),
Printer(
id=2,
name="X1C-2",
serial_number="X1C0002",
ip_address="10.0.0.2",
access_code="x",
model="X1C",
is_active=True,
),
]
)
await db.commit()
try:
yield SimpleNamespace(session_maker=session_maker)
finally:
await engine.dispose()
async def _add_item(ctx, *, printer_id=None, target_model=None):
async with ctx.session_maker() as db:
lib = LibraryFile(
filename="job.gcode.3mf",
file_path="/library/job.gcode.3mf",
file_size=10,
file_type="gcode.3mf",
file_metadata={"sliced_for_model": "X1C"},
)
db.add(lib)
await db.flush()
item = PrintQueueItem(
status="pending",
position=1,
printer_id=printer_id,
target_model=target_model,
library_file_id=lib.id,
)
db.add(item)
await db.commit()
return item.id
async def _run(ctx, scheduler, blocked, launched, finder=None, idle=True, drying=None):
patches = [
patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
patch("backend.app.core.database.async_session", ctx.session_maker),
patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
patch(
"backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
AsyncMock(return_value=blocked),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_waiting",
AsyncMock(),
),
patch(
"backend.app.services.notification_service.notification_service.on_queue_job_assigned",
AsyncMock(),
),
patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
patch.object(scheduler, "_check_auto_drying", drying or AsyncMock()),
patch.object(scheduler, "_ensure_ams_mapping", AsyncMock(return_value=None)),
patch.object(scheduler, "_block_on_filament_deficit", AsyncMock(return_value=False)),
patch.object(scheduler, "_launch_uploads", launched),
]
if finder is not None:
patches.append(patch.object(scheduler, "_find_idle_printer_for_model", finder))
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
return await scheduler.check_queue()
async def _get_item(ctx, item_id):
async with ctx.session_maker() as db:
return (await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
class TestFixedPrinter:
@pytest.mark.asyncio
async def test_holds_the_item_and_says_why(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, launched)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.waiting_reason == "Waiting on Enclosure Door"
@pytest.mark.asyncio
async def test_holding_is_not_failing(self, queue_db):
"""The door being open is a thing the user fixes in five seconds. The
job must be there waiting when they do, not failed."""
item_id = await _add_item(queue_db, printer_id=1)
await _run(queue_db, PrintScheduler(), {1: "Enclosure Door"}, MagicMock())
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.error_message is None
assert item.completed_at is None
@pytest.mark.asyncio
async def test_dispatches_once_the_hold_clears(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
launched = MagicMock()
await _run(queue_db, scheduler, {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_the_stale_reason_is_cleared_on_dispatch(self, queue_db):
"""Otherwise the queue shows "Waiting on Enclosure Door" against a job
that is already printing."""
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
await _run(queue_db, scheduler, {}, MagicMock())
assert (await _get_item(queue_db, item_id)).waiting_reason is None
@pytest.mark.asyncio
async def test_the_reason_clears_even_when_the_printer_is_still_busy(self, queue_db):
"""You shut the door, but the printer is midway through something else.
The hold has lifted and the queue must say so. Clearing the reason only
at dispatch would leave a shut door reading "Waiting on Enclosure Door"
for the rest of a ten-hour print.
"""
item_id = await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock())
launched = MagicMock()
await _run(queue_db, scheduler, {}, launched, idle=False)
launched.assert_not_called()
assert (await _get_item(queue_db, item_id)).waiting_reason is None
@pytest.mark.asyncio
async def test_another_printers_sensor_does_not_hold_this_one(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {2: "Enclosure Door"}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_nothing_blocked_dispatches_as_before(self, queue_db):
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
await _run(queue_db, PrintScheduler(), {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
@pytest.mark.asyncio
async def test_a_held_printer_is_not_reported_as_busy(self, queue_db):
"""A held printer is idle, not printing, and the rest of the scheduler
must keep seeing it that way.
busy_printers looks like the obvious place to put a hold, but
_check_auto_drying reads that set as "is currently printing" and would
route an idle-but-held printer down the mid-print drying path — capped
temperature, and past the queue-only gating. Auto-drying must see this
printer exactly as it did before the interlock existed.
"""
await _add_item(queue_db, printer_id=1)
scheduler = PrintScheduler()
drying = AsyncMock()
await _run(queue_db, scheduler, {1: "Enclosure Door"}, MagicMock(), drying=drying)
busy_printers = drying.await_args[0][2]
assert 1 not in busy_printers
@pytest.mark.asyncio
async def test_a_failing_interlock_lookup_never_stops_the_queue(self, queue_db):
"""If the check itself breaks, the answer is "no holds", not "no prints"."""
item_id = await _add_item(queue_db, printer_id=1)
launched = MagicMock()
broken = AsyncMock(side_effect=RuntimeError("HA sensor table is on fire"))
with patch(
"backend.app.services.print_scheduler.ha_sensor_manager.blocked_printers",
broken,
):
await _run(queue_db, PrintScheduler(), {}, launched)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
class TestModelBased:
def _finder(self):
"""Stand-in matcher that respects busy_printers, as the real one does.
That is the whole contract under test here: the interlock folds held
printers into busy_printers, so a matcher that honours the set honours
the interlock without knowing it exists.
"""
async def finder(db, model, busy, *args, **kwargs):
for printer_id in (1, 2):
if printer_id not in busy:
return printer_id, None
return None, "All printers busy"
return finder
@pytest.mark.asyncio
async def test_an_interlocked_printer_is_passed_over_for_its_sibling(self, queue_db):
"""The whole reason the hold is folded into busy_printers: an "Any X1C"
job should run on the printer whose door is shut, not queue behind the
one whose door is open."""
item_id = await _add_item(queue_db, target_model="X1C")
launched = MagicMock()
await _run(
queue_db,
PrintScheduler(),
{1: "Enclosure Door"},
launched,
finder=self._finder(),
)
launched.assert_called_once()
assert launched.call_args[0][0] == [item_id]
assert (await _get_item(queue_db, item_id)).printer_id == 2
@pytest.mark.asyncio
async def test_every_printer_held_leaves_the_job_waiting(self, queue_db):
item_id = await _add_item(queue_db, target_model="X1C")
launched = MagicMock()
await _run(
queue_db,
PrintScheduler(),
{1: "Enclosure Door", 2: "Enclosure Door"},
launched,
finder=self._finder(),
)
launched.assert_not_called()
item = await _get_item(queue_db, item_id)
assert item.status == "pending"
assert item.printer_id is None
@@ -0,0 +1,247 @@
"""Configuring an AMS slot must reach the printer card without a page reload.
`on_printer_status_change` deduplicates WebSocket broadcasts against a
`status_key`. Its AMS component used to carry only id / tray_type / state, so
re-configuring a slot to a different brand or colour of the SAME material
produced an identical key: the printer's pushall arrived with the new values,
the handler compared, found no change, and returned without broadcasting. The
card then showed the old filament until the 30s fallback poll or an F5.
Reset never had the bug — it clears tray_type, which was always in the key.
That asymmetry is what these tests pin: every field Configure Slot writes has
to move the key, and the fields that churn every second still must not.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app import main as main_module
def _spawn_patch():
"""Close the reconcile coroutine the handler builds as a call argument.
Same reason as test_printer_offline_notification.py: a bare MagicMock keeps
it alive in call_args and it finalises unawaited during a later test's GC.
"""
return patch(
"backend.app.main.spawn_background_task",
side_effect=lambda coro, **kwargs: coro.close(),
)
def _tray(**overrides) -> dict:
"""One AMS tray as the firmware reports it, mid-way through a print job.
Defaults describe a configured slot: Bambu PLA Basic in black, bound to
calibration slot 3.
"""
tray = {
"id": "0",
"tray_type": "PLA",
"state": 10,
"tray_color": "000000FF",
"tray_info_idx": "GFA00",
"tray_sub_brands": "PLA Basic",
"cali_idx": 3,
"remain": 42,
}
tray.update(overrides)
return tray
def _state(trays: list[dict]) -> SimpleNamespace:
"""Minimal PrinterState stub carrying one AMS unit.
Idle and unheated, so the handler runs straight from the dedup check to the
broadcast without touching progress milestones, HMS notifications or the DB.
"""
return SimpleNamespace(
connected=True,
state="IDLE",
progress=0,
layer_num=0,
temperatures={},
raw_data={"ams": [{"id": "0", "dry_time": 0, "tray": trays}]},
stg_cur=0,
cooling_fan_speed=0,
big_fan1_speed=0,
big_fan2_speed=0,
chamber_light="",
active_extruder=0,
tray_now=0,
door_open=False,
subtask_name="",
gcode_file="",
remaining_time=None,
hms_errors=[],
ams_filament_backup=None,
)
@pytest.fixture(autouse=True)
def _reset_edge_state():
main_module._last_status_broadcast.clear()
main_module._printer_last_connected.clear()
main_module._printer_reconciled_since_connect.clear()
yield
main_module._last_status_broadcast.clear()
main_module._printer_last_connected.clear()
main_module._printer_reconciled_since_connect.clear()
async def _push(ws_mgr, trays: list[dict]) -> None:
"""Deliver one status push to the handler."""
relay = MagicMock()
relay.on_printer_status = AsyncMock()
pm = MagicMock()
pm.get_printer.return_value = None # Skip the relay payload branch.
pm.get_model.return_value = ""
with (
patch("backend.app.main.ws_manager", ws_mgr),
patch("backend.app.main.mqtt_relay", relay),
patch("backend.app.main.printer_manager", pm),
_spawn_patch(),
patch("backend.app.main.printer_state_to_dict", return_value={}),
):
await main_module.on_printer_status_change(1, _state(trays))
@pytest.fixture
def ws_mgr():
mgr = MagicMock()
mgr.send_printer_status = AsyncMock()
return mgr
class TestConfigureSlotBroadcasts:
"""Each field Configure Slot writes must break the dedup on its own —
the user may change only the colour, or only the K-profile."""
@pytest.mark.asyncio
@pytest.mark.parametrize(
"field,new_value",
[
("tray_color", "FF0000FF"),
("tray_info_idx", "GFA01"),
("tray_sub_brands", "PLA Matte"),
("cali_idx", 7),
],
)
async def test_a_changed_filament_field_broadcasts(self, ws_mgr, field, new_value):
await _push(ws_mgr, [_tray()])
assert ws_mgr.send_printer_status.await_count == 1
await _push(ws_mgr, [_tray(**{field: new_value})])
assert ws_mgr.send_printer_status.await_count == 2, (
f"changing {field} did not reach the frontend — the card would keep "
"showing the old filament until the fallback poll"
)
@pytest.mark.asyncio
async def test_the_realistic_reconfigure_broadcasts(self, ws_mgr):
"""Black Bambu PLA Basic → red eSUN PLA+ with its own K-profile.
The whole point of the report: same material, so every field the old key
looked at is unchanged.
"""
await _push(ws_mgr, [_tray()])
await _push(
ws_mgr,
[
_tray(
tray_color="C1121FFF",
tray_info_idx="GFL99",
tray_sub_brands="eSUN PLA+",
cali_idx=5,
)
],
)
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_a_second_slot_is_watched_too(self, ws_mgr):
"""The key spans every tray, so configuring slot 2 must broadcast even
though slot 1 is untouched."""
trays = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG00")]
await _push(ws_mgr, trays)
changed = [_tray(id="0"), _tray(id="1", tray_type="PETG", tray_info_idx="GFG01")]
await _push(ws_mgr, changed)
assert ws_mgr.send_printer_status.await_count == 2
class TestDedupStillHolds:
"""The dedup exists to keep a printing machine from flooding the socket.
Widening the key must not have cost that."""
@pytest.mark.asyncio
async def test_an_identical_push_is_still_suppressed(self, ws_mgr):
await _push(ws_mgr, [_tray()])
await _push(ws_mgr, [_tray()])
assert ws_mgr.send_printer_status.await_count == 1
@pytest.mark.asyncio
async def test_remaining_filament_does_not_broadcast(self, ws_mgr):
"""`remain` ticks down throughout a print and is deliberately absent
from the key. It sits in the same tray dict as the fields we added, so
this pins that we widened the key rather than hashing the whole tray."""
await _push(ws_mgr, [_tray(remain=42)])
await _push(ws_mgr, [_tray(remain=41)])
assert ws_mgr.send_printer_status.await_count == 1
class TestExistingBehaviourUnchanged:
"""The cases that already worked, kept working."""
@pytest.mark.asyncio
async def test_a_load_unload_transition_still_broadcasts(self, ws_mgr):
"""#784 — tray state 11→10."""
await _push(ws_mgr, [_tray(state=11)])
await _push(ws_mgr, [_tray(state=10)])
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_resetting_a_slot_still_broadcasts(self, ws_mgr):
"""Reset clears the filament identity outright."""
await _push(ws_mgr, [_tray()])
await _push(
ws_mgr,
[_tray(tray_type="", tray_color="", tray_info_idx="", tray_sub_brands="", cali_idx=-1)],
)
assert ws_mgr.send_printer_status.await_count == 2
@pytest.mark.asyncio
async def test_a_printer_with_no_ams_still_broadcasts_once(self, ws_mgr):
"""The `else ()` branch — an AMS-less printer must not crash or
double-broadcast."""
relay = MagicMock()
relay.on_printer_status = AsyncMock()
pm = MagicMock()
pm.get_printer.return_value = None
pm.get_model.return_value = ""
state = _state([])
state.raw_data = {}
for _ in range(2):
with (
patch("backend.app.main.ws_manager", ws_mgr),
patch("backend.app.main.mqtt_relay", relay),
patch("backend.app.main.printer_manager", pm),
_spawn_patch(),
patch("backend.app.main.printer_state_to_dict", return_value={}),
):
await main_module.on_printer_status_change(1, state)
assert ws_mgr.send_printer_status.await_count == 1
@@ -0,0 +1,91 @@
/**
* The Home Assistant sensor modal (#1148, #448).
*
* Focused on the unconfigured-Home-Assistant path, which is the state a first
* time user is actually in: the entity picker can only ever come back empty
* there, and an empty list reads as "I have no sensors" rather than as "you
* have not connected Home Assistant yet".
*/
import { screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HASensorModal } from '../../components/HASensorModal';
import { api } from '../../api/client';
import { render } from '../utils';
vi.mock('../../api/client', async () => {
const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
return {
...actual,
api: { ...actual.api, getSettings: vi.fn(), getBindableHAEntities: vi.fn() },
};
});
const getSettings = vi.mocked(api.getSettings);
const getEntities = vi.mocked(api.getBindableHAEntities);
const PRINTERS = [{ id: 4, name: 'X1C-1' }] as never;
function settings(overrides = {}) {
return {
ha_enabled: true,
ha_url: 'http://homeassistant.local:8123',
ha_token: 'token',
...overrides,
} as never;
}
describe('HASensorModal', () => {
beforeEach(() => {
getSettings.mockReset();
getEntities.mockReset();
getEntities.mockResolvedValue([]);
});
it('warns when Home Assistant is not configured at all', async () => {
getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' }));
render(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
expect(
await screen.findByText(/Home Assistant is not configured/)
).toBeInTheDocument();
expect(screen.getByText('Settings → Network → Home Assistant')).toBeInTheDocument();
});
it('warns when the integration is configured but switched off', async () => {
getSettings.mockResolvedValue(settings({ ha_enabled: false }));
render(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
expect(await screen.findByText(/Home Assistant is not configured/)).toBeInTheDocument();
});
it('does not ask Home Assistant for entities it cannot reach', async () => {
getSettings.mockResolvedValue(settings({ ha_token: '' }));
render(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
await screen.findByText(/Home Assistant is not configured/);
expect(getEntities).not.toHaveBeenCalled();
});
it('blocks saving a new sensor while unconfigured', async () => {
getSettings.mockResolvedValue(settings({ ha_enabled: false }));
render(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
await screen.findByText(/Home Assistant is not configured/);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});
it('shows no warning once Home Assistant is configured', async () => {
getSettings.mockResolvedValue(settings());
render(<HASensorModal printers={PRINTERS} onClose={() => {}} />);
await waitFor(() => expect(getEntities).toHaveBeenCalled());
expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from '../utils';
import { ModelViewerModal } from '../../components/ModelViewerModal';
import { setStreamToken } from '../../api/client';
import { openInSlicer } from '../../utils/slicer';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
@@ -28,6 +29,15 @@ vi.mock('../../components/GcodeViewer', () => ({
),
}));
// Only the protocol-handler launch is stubbed — it would navigate the jsdom
// window. Everything else in the module is a pure predicate, so keep the real
// implementations: re-declaring them here would let the file-type rule these
// tests assert on drift away from the one the component actually runs.
vi.mock('../../utils/slicer', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/slicer')>()),
openInSlicer: vi.fn(),
}));
const mockCapabilities = {
has_model: true,
has_gcode: true,
@@ -512,12 +522,12 @@ describe('ModelViewerModal', () => {
});
});
it('disables Open in Slicer for non-3mf library files', async () => {
it('disables Open in Slicer for library files that cannot be handed to a slicer', async () => {
render(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
title="Model.gcode"
fileType="gcode"
onClose={mockOnClose}
/>
);
@@ -528,4 +538,235 @@ describe('ModelViewerModal', () => {
});
});
});
describe('slicer split button (#2725)', () => {
it('shows both slicers in the dropdown when Bambuddy is the default slicer', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json(mockSinglePlateResponse);
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.3mf"
fileType="3mf"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
});
it('opens the selected local slicer from the Bambuddy dropdown', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
}),
http.get('/api/v1/library/files/:id/plates', () => {
return HttpResponse.json(mockSinglePlateResponse);
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.3mf"
fileType="3mf"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
const orcaItem = await screen.findByText('Open in OrcaSlicer');
fireEvent.click(orcaItem);
await waitFor(() => {
expect(openInSlicer).toHaveBeenCalledWith(expect.any(String), 'orcaslicer');
});
});
it('shows only the non-preferred slicer in the dropdown for a desktop handoff', async () => {
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
expect(screen.queryByText('Open in Bambu Studio')).not.toBeInTheDocument();
});
it('offers Bambu Studio when the preferred desktop slicer is OrcaSlicer', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ preferred_slicer: 'orcaslicer' });
})
);
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
});
expect(screen.queryByText('Open in OrcaSlicer')).not.toBeInTheDocument();
});
it('closes the split dropdown on Escape without closing the modal', async () => {
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
});
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(mockOnClose).not.toHaveBeenCalled();
});
it('closes the split dropdown on an outside click without closing the modal', async () => {
render(
<ModelViewerModal
archiveId={1}
title="Test Model"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByRole('menu')).toBeInTheDocument();
});
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(mockOnClose).not.toHaveBeenCalled();
});
it('does not render a split chevron when the file cannot open in a slicer', async () => {
render(
<ModelViewerModal
libraryFileId={1}
title="Model.gcode"
fileType="gcode"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeDisabled();
});
expect(screen.queryByRole('button', { name: 'More slicer options' })).not.toBeInTheDocument();
});
it('offers the desktop handoff for an STL library file', async () => {
render(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
onClose={mockOnClose}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Open in Slicer' })).toBeEnabled();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
});
it('offers the split Slice button for an STL when the slicer API is enabled', async () => {
server.use(
http.get('/api/v1/settings/', () => {
return HttpResponse.json({ use_slicer_api: true });
})
);
render(
<ModelViewerModal
libraryFileId={1}
title="Model.stl"
fileType="stl"
onClose={mockOnClose}
onSliceWithBambuddy={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Slice' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'More slicer options' }));
await waitFor(() => {
expect(screen.getByText('Open in Bambu Studio')).toBeInTheDocument();
expect(screen.getByText('Open in OrcaSlicer')).toBeInTheDocument();
});
});
});
});
@@ -0,0 +1,161 @@
/**
* The Home Assistant sensor row on the printer card (#1148, #448).
*
* The reporter's whole ask is a glance-and-know row: "is the enclosure shut?"
* So the assertions are about what the pill actually says. "on" is not an
* answer to that question — "Open" is, and only Home Assistant's device_class
* tells us which word to use.
*/
import { screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PrinterHASensorRow } from '../../components/PrinterHASensorRow';
import { api } from '../../api/client';
import { render } from '../utils';
vi.mock('../../api/client', async () => {
const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
return { ...actual, api: { ...actual.api, getHASensorReadings: vi.fn() } };
});
const getReadings = vi.mocked(api.getHASensorReadings);
function reading(overrides = {}) {
return {
id: 1,
name: 'Enclosure Door',
entity_id: 'binary_sensor.enclosure_door',
kind: 'binary' as const,
device_class: 'door',
unit: null,
state: 'off',
value: null,
alerting: false,
block_print: false,
reachable: true,
last_changed: null,
...overrides,
};
}
describe('PrinterHASensorRow', () => {
beforeEach(() => {
getReadings.mockReset();
});
it('renders nothing when the printer has no sensors', async () => {
getReadings.mockResolvedValue([]);
render(<PrinterHASensorRow printerId={4} />);
await waitFor(() => expect(getReadings).toHaveBeenCalledWith(4));
// No label, no divider, no empty-state placeholder — the row must not
// reserve space on every card that has no sensors configured.
expect(screen.queryByText('Sensors')).not.toBeInTheDocument();
});
it('names a door state by its device class, not by on/off', async () => {
getReadings.mockResolvedValue([reading({ state: 'off' })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Closed')).toBeInTheDocument();
expect(screen.getByText('Enclosure Door')).toBeInTheDocument();
});
it('says Open for the same sensor in the other state', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Open')).toBeInTheDocument();
});
it('falls back to on/off for a class it has no wording for', async () => {
getReadings.mockResolvedValue([reading({ device_class: null, state: 'on' })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('On')).toBeInTheDocument();
});
it('shows a numeric reading with its unit', async () => {
getReadings.mockResolvedValue([
reading({
kind: 'numeric',
device_class: 'temperature',
entity_id: 'sensor.enclosure_temp',
name: 'Enclosure Temp',
unit: '°C',
state: '41.2',
value: 41.2,
}),
]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('41.2 °C')).toBeInTheDocument();
});
it('reports an unreachable sensor as unavailable rather than as a state', async () => {
// The pill must not read "Closed" for a door contact that dropped off the
// network — that is the one wrong answer with real consequences.
getReadings.mockResolvedValue([reading({ state: null, reachable: false })]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Unavailable')).toBeInTheDocument();
expect(screen.queryByText('Closed')).not.toBeInTheDocument();
});
it('marks an alerting sensor for the eye', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: true })]);
render(<PrinterHASensorRow printerId={4} />);
const pill = (await screen.findByText('Open')).closest('span[title]');
expect(pill?.className).toContain('red');
});
it('does not colour a quiet sensor as an alert', async () => {
getReadings.mockResolvedValue([reading({ state: 'on', alerting: false })]);
render(<PrinterHASensorRow printerId={4} />);
const pill = (await screen.findByText('Open')).closest('span[title]');
expect(pill?.className).not.toContain('red');
});
it('says so in the tooltip when a sensor holds prints', async () => {
getReadings.mockResolvedValue([reading({ block_print: true })]);
render(<PrinterHASensorRow printerId={4} />);
await screen.findByText('Closed');
expect(
screen.getByTitle('binary_sensor.enclosure_door — holds prints while alerting')
).toBeInTheDocument();
});
it('lists every sensor bound to the printer', async () => {
getReadings.mockResolvedValue([
reading(),
reading({
id: 2,
kind: 'numeric',
name: 'Enclosure Temp',
entity_id: 'sensor.enclosure_temp',
device_class: 'temperature',
unit: '°C',
state: '22',
value: 22,
}),
]);
render(<PrinterHASensorRow printerId={4} />);
expect(await screen.findByText('Enclosure Door')).toBeInTheDocument();
expect(screen.getByText('Enclosure Temp')).toBeInTheDocument();
});
});
@@ -623,16 +623,23 @@ describe('useWebSocket hook', () => {
/**
* #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
* front, and caught up all at once on switching back. The cache writes ran
* inside requestAnimationFrame, and a hidden tab gets no rendering
* opportunities — so the browser holds queued frame callbacks indefinitely
* rather than merely throttling them.
* front, and caught up all at once on switching back.
*
* The stub below is what makes these tests meaningful: it hands back a
* handle and never invokes the callback, which is what a real hidden tab
* does. `document.hidden` is set alongside it to name the scenario, but the
* production code doesn't branch on visibility — it simply no longer defers
* to a frame. Reintroduce a rAF wrapper on either path and these fail.
* Two causes, fixed in two rounds. First the cache writes ran inside a
* requestAnimationFrame, and a hidden tab gets no rendering opportunities —
* the browser holds queued frame callbacks indefinitely rather than merely
* throttling them. The rAF stub below is what makes those tests meaningful:
* it hands back a handle and never invokes the callback, which is what a
* real hidden tab does.
*
* Removing the frame callback did not close the report, because the 100ms
* coalescing timer was still in the path and a hidden page's timers are
* clamped to at best once a second — once a minute past five minutes hidden.
* So the writes must not depend on a timer either while hidden, which is
* what `writes without waiting on a timer` pins down. Note it deliberately
* never advances the clock: a test that advances fake timers cannot tell a
* throttled timer from a prompt one, which is exactly why the original tests
* kept passing while the reporter's tab stayed frozen.
*/
describe('hidden tab (#2754)', () => {
let rafSpy: ReturnType<typeof vi.fn>;
@@ -690,6 +697,49 @@ describe('useWebSocket hook', () => {
expect(rafSpy).not.toHaveBeenCalled();
});
it('writes without waiting on a timer', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
const ws = await waitForWs();
act(() => ws.open());
act(() => {
ws.simulateMessage({
type: 'printer_status',
printer_id: 1,
data: { state: 'RUNNING', progress: 42 },
});
});
// No advanceTimersByTime: a hidden tab's timers are throttled to once a
// second at best, so anything the title depends on has to have landed
// already. Reintroduce the coalescing timer on this path and the cache
// is still empty here.
expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
state: 'RUNNING',
progress: 42,
});
});
it('applies the newest value when several arrive before a frame would have run', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
const ws = await waitForWs();
act(() => ws.open());
act(() => {
ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 40 } });
ws.simulateMessage({ type: 'printer_status', printer_id: 1, data: { progress: 41 } });
});
// Writing through per message must not resurrect an earlier one: the
// pending map is drained on each flush, so a stale entry cannot be
// re-applied over the newer value.
expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 41 });
});
it('drains queued messages instead of wedging the queue', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -715,6 +765,46 @@ describe('useWebSocket hook', () => {
});
});
describe('visible tab still coalesces (#2754)', () => {
/**
* The counterpart to the hidden-tab block: the write-through is scoped to
* a hidden tab on purpose. A visible one is painting, and the 100ms window
* is what stops a burst of status messages turning into a render cascade —
* so "just always write through" is not the simplification it looks like.
*/
it('defers the write while the tab is visible', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
vi.useFakeTimers();
try {
renderHook(() => useWebSocket(), { wrapper: createWrapper(client) });
const ws = await waitForWs();
act(() => ws.open());
act(() => {
ws.simulateMessage({
type: 'printer_status',
printer_id: 1,
data: { state: 'RUNNING', progress: 42 },
});
});
expect(client.getQueryData(['printerStatus', 1])).toBeUndefined();
await act(async () => {
vi.advanceTimersByTime(200);
});
expect(client.getQueryData(['printerStatus', 1])).toMatchObject({ progress: 42 });
} finally {
vi.useRealTimers();
}
});
});
describe('sendMessage', () => {
it('sends JSON message when connected', async () => {
const { useWebSocket } = await import('../../hooks/useWebSocket');
@@ -3,13 +3,30 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { FileManagerPage } from '../../pages/FileManagerPage';
import { openInSlicer } from '../../utils/slicer';
import { setAuthToken } from '../../api/client';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
// Only the protocol-handler launch is stubbed — it would navigate the jsdom
// window. Everything else in the module is a pure predicate, so keep the real
// implementations: isSliceableFilename decides which rows even offer the
// action these tests click.
vi.mock('../../utils/slicer', async (importOriginal) => ({
...(await importOriginal<typeof import('../../utils/slicer')>()),
openInSlicer: vi.fn(),
}));
vi.mock('../../components/SliceModal', () => ({
SliceModal: ({ source }: { source: { filename: string } }) => (
<div data-testid="slice-modal">{source.filename}</div>
),
}));
// Mock data
const mockFolders = [
{
@@ -1155,4 +1172,215 @@ describe('FileManagerPage', () => {
});
});
});
describe('slice action', () => {
beforeEach(() => {
vi.mocked(openInSlicer).mockClear();
server.use(
http.post('/api/v1/library/files/:id/slicer-token', () => HttpResponse.json({ token: 'test-token' })),
);
});
afterEach(() => {
// Permission tests set a token; clear it so it can't leak into the
// list-view tests that follow (mirrors FileManagerFolderDelete.test.tsx).
setAuthToken(null);
});
const openMenu = async (user: ReturnType<typeof userEvent.setup>, filename: string) => {
const card = screen.getByText(filename).closest('.group') as HTMLElement;
// Target the kebab (ellipsis) toggle specifically rather than the card's
// first button — a button added ahead of the kebab would otherwise
// break the menu-opening assumption.
const kebab = card.querySelector('.lucide-ellipsis-vertical')?.closest('button') as HTMLButtonElement;
await user.click(kebab);
return card;
};
it('opens the desktop slicer when the slicer API is disabled', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
await user.click(within(card).getByText('Slice'));
await waitFor(() => {
expect(openInSlicer).toHaveBeenCalledWith(
expect.stringContaining('/library/files/2/dl/test-token/'),
'bambu_studio',
);
});
});
it('opens the in-app SliceModal when the slicer API is enabled', async () => {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
await user.click(within(card).getByText('Slice'));
expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
expect(openInSlicer).not.toHaveBeenCalled();
});
it('hides the slice item for already-sliced files', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('Benchy')).toBeInTheDocument());
const card = await openMenu(user, 'Benchy');
expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
});
// Permission gating is the security-relevant half of the slice action: the
// in-app API path needs library:upload, and the desktop handoff mirrors the
// ownership check the slicer-token endpoint runs — library:read_all or
// library:read_own. The legacy library:read is deliberately not accepted;
// it satisfies neither the token endpoint nor the folder listing that gets
// a user to this page at all.
const mockAuthUser = (permissions: string[]) => {
setAuthToken('test-token', 'session');
server.use(
http.get('*/api/v1/auth/status', () =>
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
),
http.get('*/api/v1/auth/me', () =>
HttpResponse.json({
id: 7,
username: 'operator1',
is_admin: false,
permissions,
}),
),
http.get('/api/v1/users/', () => HttpResponse.json([])),
);
};
it('disables the Slice menu item without library:upload when the slicer API is enabled', async () => {
mockAuthUser([]);
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
const sliceItem = within(card).getByText('Slice').closest('button');
expect(sliceItem).toBeDisabled();
await user.click(sliceItem!);
expect(openInSlicer).not.toHaveBeenCalled();
});
it('enables the Slice menu item with library:upload when the slicer API is enabled', async () => {
mockAuthUser(['library:upload']);
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
const sliceItem = within(card).getByText('Slice').closest('button');
expect(sliceItem).not.toBeDisabled();
});
it('disables the Slice menu item without any library read permission for the desktop handoff', async () => {
mockAuthUser(['library:upload']);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
const sliceItem = within(card).getByText('Slice').closest('button');
expect(sliceItem).toBeDisabled();
await user.click(sliceItem!);
expect(openInSlicer).not.toHaveBeenCalled();
});
it('enables the Slice menu item with library:read_own for the desktop handoff', async () => {
mockAuthUser(['library:read_own']);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
const sliceItem = within(card).getByText('Slice').closest('button');
expect(sliceItem).not.toBeDisabled();
});
it('does not accept the legacy library:read for the desktop handoff', async () => {
// require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
// legacy expansion, so this group 403s on the slicer-token endpoint.
// Enabling the item would offer an action the server refuses, and the
// failure would look like "no slicer installed" once the fallback URL is
// handed over.
mockAuthUser(['library:read']);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const card = await openMenu(user, 'bracket.stl');
const sliceItem = within(card).getByText('Slice').closest('button');
expect(sliceItem).toBeDisabled();
await user.click(sliceItem!);
expect(openInSlicer).not.toHaveBeenCalled();
});
it('slices from the list-view button when the slicer API is disabled', async () => {
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
await user.click(screen.getByTitle('List view'));
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
await user.click(within(row).getByTitle('Slice'));
await waitFor(() => {
expect(openInSlicer).toHaveBeenCalledWith(
expect.stringContaining('/library/files/2/dl/test-token/'),
'bambu_studio',
);
});
});
it('slices from the list-view button into the in-app modal when the slicer API is enabled', async () => {
server.use(
http.get('/api/v1/settings/', () => HttpResponse.json({ use_slicer_api: true })),
);
const user = userEvent.setup();
render(<FileManagerPage />);
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
await user.click(screen.getByTitle('List view'));
await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
const row = screen.getByText('bracket.stl').closest('div[class*="cursor-pointer"]') as HTMLElement;
await user.click(within(row).getByTitle('Slice'));
expect(await screen.findByTestId('slice-modal')).toBeInTheDocument();
expect(openInSlicer).not.toHaveBeenCalled();
});
});
});
@@ -61,6 +61,8 @@ function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
// Individual tests layer extra handlers on top via ``server.use``.
function useAuthedHandlers(opts: {
slicer?: 'bambu_studio' | 'orcaslicer';
openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
useSlicerApi?: boolean;
recent?: Array<Record<string, unknown>>;
} = {}) {
const slicer = opts.slicer ?? 'bambu_studio';
@@ -74,6 +76,8 @@ function useAuthedHandlers(opts: {
auto_archive: true,
save_thumbnails: true,
preferred_slicer: slicer,
...(opts.openInSlicer !== undefined ? { open_in_slicer: opts.openInSlicer } : {}),
...(opts.useSlicerApi !== undefined ? { use_slicer_api: opts.useSlicerApi } : {}),
}),
),
);
@@ -190,6 +194,46 @@ describe('MakerworldPage', () => {
expect(sliceButtons.length).toBe(2);
});
it('prefers the open_in_slicer override over preferred_slicer for the desktop handoff', async () => {
// The URI-handoff label resolves via resolveDesktopSlicer: open_in_slicer
// beats preferred_slicer (#1329) when the slicer API is off.
useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer' });
server.use(
http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
);
render(<MakerworldPage />);
await userEvent.type(
await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
'https://makerworld.com/en/models/1400373',
);
await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
const sliceButtons = await screen.findAllByRole('button', {
name: /Save & Slice in OrcaSlicer/,
});
expect(sliceButtons.length).toBe(2);
});
it('keeps preferred_slicer for the in-app API slice label, ignoring open_in_slicer', async () => {
// With the slicer API on, the sidecar drives the button, so the
// open_in_slicer desktop override must not leak into the label.
useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer', useSlicerApi: true });
server.use(
http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
);
render(<MakerworldPage />);
await userEvent.type(
await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
'https://makerworld.com/en/models/1400373',
);
await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
const sliceButtons = await screen.findAllByRole('button', {
name: /Save & Slice in Bambu Studio/,
});
expect(sliceButtons.length).toBe(2);
});
it('clears the resolved preview when the URL input is edited after resolve', async () => {
useAuthedHandlers();
server.use(
+88
View File
@@ -2167,6 +2167,75 @@ export interface HATestConnectionResult {
error: string | null;
}
// A Home Assistant entity bound to a printer for display on its card (#1148, #448).
// Read-only: unlike a SmartPlug there is nothing here to switch.
export interface PrinterHASensor {
id: number;
printer_id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null; // HA's own class: "door", "temperature", ...
unit: string | null; // numeric sensors only
// What counts as needing attention. Binary sensors use alert_state, numeric
// ones the thresholds; all null means the sensor is display-only.
alert_state: 'on' | 'off' | null;
alert_above: number | null;
alert_below: number | null;
block_print: boolean; // hold the printer's queue while alerting
notify_on_alert: boolean;
show_on_printer_card: boolean;
sort_order: number;
last_state: string | null;
last_changed: string | null;
last_checked: string | null;
created_at: string;
updated_at: string;
}
export interface PrinterHASensorReading {
id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null;
unit: string | null;
state: string | null; // null when unreadable or not yet polled
value: number | null; // numeric sensors only
alerting: boolean;
block_print: boolean;
reachable: boolean;
last_changed: string | null;
}
export interface PrinterHASensorCreate {
printer_id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class?: string | null;
unit?: string | null;
alert_state?: 'on' | 'off' | null;
alert_above?: number | null;
alert_below?: number | null;
block_print?: boolean;
notify_on_alert?: boolean;
show_on_printer_card?: boolean;
sort_order?: number;
}
export type PrinterHASensorUpdate = Partial<Omit<PrinterHASensorCreate, 'printer_id'>>;
// An entity offered by the binding picker.
export interface HADisplayEntity {
entity_id: string;
friendly_name: string;
state: string | null;
domain: string; // "binary_sensor" | "sensor"
device_class: string | null;
unit_of_measurement: string | null;
}
export interface SmartPlugEnergy {
power: number | null; // Current watts
voltage: number | null; // Volts
@@ -2615,6 +2684,7 @@ export interface NotificationProvider {
on_plate_clear_required: boolean;
// Bed cooled
on_bed_cooled: boolean;
on_ha_sensor_alert: boolean;
// First layer complete
on_first_layer_complete: boolean;
// Inventory stock alerts
@@ -2675,6 +2745,7 @@ export interface NotificationProviderCreate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@@ -2728,6 +2799,7 @@ export interface NotificationProviderUpdate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@@ -5277,6 +5349,22 @@ export const api = {
getHASensorEntities: () =>
request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
// Home Assistant sensors bound to a printer (#1148, #448)
getHASensors: (printerId?: number) =>
request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
getHASensorReadings: (printerId: number) =>
request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
getBindableHAEntities: (search?: string) => {
const params = search ? `?search=${encodeURIComponent(search)}` : '';
return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
},
createHASensor: (data: PrinterHASensorCreate) =>
request<PrinterHASensor>('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
updateHASensor: (id: number, data: PrinterHASensorUpdate) =>
request<PrinterHASensor>(`/ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteHASensor: (id: number) =>
request<{ message: string }>(`/ha-sensors/${id}`, { method: 'DELETE' }),
// REST smart plug
testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {
@@ -45,6 +45,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onStockBreakAlert, setOnStockBreakAlert] = useState(provider?.on_stock_break_alert ?? false);
const [onPlateClearRequired, setOnPlateClearRequired] = useState(provider?.on_plate_clear_required ?? false);
const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
const [onHaSensorAlert, setOnHaSensorAlert] = useState(provider?.on_ha_sensor_alert ?? false);
const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
// Provider-specific config (scalar fields only — event_priorities is split out
@@ -200,6 +201,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_stock_break_alert: onStockBreakAlert,
on_plate_clear_required: onPlateClearRequired,
on_bed_cooled: onBedCooled,
on_ha_sensor_alert: onHaSensorAlert,
on_first_layer_complete: onFirstLayerComplete,
};
@@ -635,6 +637,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<span className="text-sm text-white">{t('notifications.offline')}</span>
<Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} />
</div>
<div className="flex items-center justify-between col-span-2">
<div>
<span className="text-sm text-white">{t('notifications.haSensorAlert')}</span>
<span className="text-xs text-bambu-gray ml-1">{t('notifications.haSensorAlertDescription')}</span>
</div>
<Toggle checked={onHaSensorAlert} onChange={setOnHaSensorAlert} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
@@ -688,6 +697,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
+413
View File
@@ -0,0 +1,413 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Gauge, Loader2, Save, Search, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { HADisplayEntity, Printer, PrinterHASensor } from '../api/client';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
/**
* Bind a Home Assistant entity to a printer, or edit an existing binding
* (#1148, #448).
*
* The entity picker is the load-bearing part: kind, device_class and unit all
* come from the entity rather than from the user, because getting any of them
* wrong is a validation error from the backend that nobody could act on.
*/
interface Props {
sensor?: PrinterHASensor | null;
printers: Printer[];
onClose: () => void;
}
// The alert wording follows the device class, so a door offers "Open" rather
// than "On". Shared with PrinterHASensorRow's rendering of the same classes.
const ALERT_LABEL_KEYS: Record<string, { on: string; off: string }> = {
door: { on: 'open', off: 'closed' },
garage_door: { on: 'open', off: 'closed' },
window: { on: 'open', off: 'closed' },
opening: { on: 'open', off: 'closed' },
lock: { on: 'unlocked', off: 'locked' },
motion: { on: 'detected', off: 'clear' },
occupancy: { on: 'detected', off: 'clear' },
presence: { on: 'detected', off: 'clear' },
smoke: { on: 'detected', off: 'clear' },
gas: { on: 'detected', off: 'clear' },
moisture: { on: 'wet', off: 'dry' },
problem: { on: 'problem', off: 'ok' },
safety: { on: 'problem', off: 'ok' },
running: { on: 'running', off: 'stopped' },
};
export function HASensorModal({ sensor, printers, onClose }: Props) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const isEditing = !!sensor;
const [printerId, setPrinterId] = useState<number | ''>(sensor?.printer_id ?? printers[0]?.id ?? '');
const [entityId, setEntityId] = useState(sensor?.entity_id ?? '');
const [kind, setKind] = useState<'binary' | 'numeric'>(sensor?.kind ?? 'binary');
const [deviceClass, setDeviceClass] = useState<string | null>(sensor?.device_class ?? null);
const [unit, setUnit] = useState<string | null>(sensor?.unit ?? null);
const [name, setName] = useState(sensor?.name ?? '');
const [alertState, setAlertState] = useState<'on' | 'off' | ''>(sensor?.alert_state ?? '');
const [alertAbove, setAlertAbove] = useState(sensor?.alert_above?.toString() ?? '');
const [alertBelow, setAlertBelow] = useState(sensor?.alert_below?.toString() ?? '');
const [showOnCard, setShowOnCard] = useState(sensor?.show_on_printer_card ?? true);
const [notifyOnAlert, setNotifyOnAlert] = useState(sensor?.notify_on_alert ?? false);
const [blockPrint, setBlockPrint] = useState(sensor?.block_print ?? false);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
// Same gate and the same wording as AddSmartPlugModal: without a configured
// Home Assistant the picker can only return an error, so say why up front
// instead of showing an empty list.
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const haConfigured = !!(settings?.ha_enabled && settings?.ha_url && settings?.ha_token);
const { data: entities, isLoading: entitiesLoading, error: entitiesError } = useQuery({
queryKey: ['bindableHAEntities'],
queryFn: () => api.getBindableHAEntities(),
enabled: haConfigured,
});
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const all = entities ?? [];
if (!needle) return all;
return all.filter(
(e) => e.entity_id.toLowerCase().includes(needle) || e.friendly_name.toLowerCase().includes(needle)
);
}, [entities, search]);
const selectEntity = (entity: HADisplayEntity) => {
setEntityId(entity.entity_id);
setDeviceClass(entity.device_class);
setUnit(entity.unit_of_measurement);
const nextKind = entity.domain === 'binary_sensor' ? 'binary' : 'numeric';
setKind(nextKind);
// Switching kind strands the other kind's alert fields, and the backend
// rejects a numeric sensor that still carries an alert_state.
if (nextKind === 'numeric') setAlertState('');
else {
setAlertAbove('');
setAlertBelow('');
}
// Sliced to the column width: Home Assistant friendly names have no length
// limit, and a long one would come back as a Pydantic error on a field the
// user did not type into.
if (!name.trim()) setName(entity.friendly_name.slice(0, 100));
};
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['haSensors'] });
queryClient.invalidateQueries({ queryKey: ['haSensorReadings'] });
};
const saveMutation = useMutation({
mutationFn: () => {
const payload = {
name: name.trim(),
entity_id: entityId,
kind,
device_class: deviceClass,
unit,
alert_state: kind === 'binary' && alertState ? alertState : null,
alert_above: kind === 'numeric' && alertAbove !== '' ? Number(alertAbove) : null,
alert_below: kind === 'numeric' && alertBelow !== '' ? Number(alertBelow) : null,
block_print: blockPrint,
notify_on_alert: notifyOnAlert,
show_on_printer_card: showOnCard,
};
return isEditing
? api.updateHASensor(sensor.id, payload)
: api.createHASensor({ ...payload, printer_id: Number(printerId) });
},
onSuccess: () => {
invalidate();
showToast(isEditing ? t('haSensors.toast.updated') : t('haSensors.toast.created'), 'success');
onClose();
},
onError: (err: Error) => setError(err.message),
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteHASensor(sensor!.id),
onSuccess: () => {
invalidate();
showToast(t('haSensors.toast.deleted'), 'success');
onClose();
},
onError: (err: Error) => setError(err.message),
});
const hasAlertCondition =
kind === 'binary' ? alertState !== '' : alertAbove !== '' || alertBelow !== '';
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!entityId) return setError(t('haSensors.error.pickEntity'));
if (!name.trim()) return setError(t('haSensors.error.nameRequired'));
if (printerId === '') return setError(t('haSensors.error.printerRequired'));
// Mirrors the backend rule, so the user is told before the round trip
// rather than by a 422.
if ((blockPrint || notifyOnAlert) && !hasAlertCondition) {
return setError(t('haSensors.error.alertRequired'));
}
saveMutation.mutate();
};
const alertLabels = ALERT_LABEL_KEYS[deviceClass ?? ''];
const stateLabel = (which: 'on' | 'off') => {
const key = alertLabels?.[which] ?? which;
return t(`haSensors.states.${key}`, { defaultValue: key });
};
const isPending = saveMutation.isPending || deleteMutation.isPending;
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
<div
className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
<Gauge className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-white">
{isEditing ? t('haSensors.editTitle') : t('haSensors.addTitle')}
</h2>
</div>
<button onClick={onClose} className="text-bambu-gray hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-100 dark:bg-red-500/20 border border-red-300 dark:border-red-500/50 rounded-lg text-sm text-red-700 dark:text-red-400">
{error}
</div>
)}
{!isEditing && (
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.printer')}</label>
<select
value={printerId}
onChange={(e) => setPrinterId(e.target.value === '' ? '' : Number(e.target.value))}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
>
{printers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
{!haConfigured && (
<div className="p-3 bg-yellow-100 dark:bg-yellow-500/20 border border-yellow-400 dark:border-yellow-500/50 rounded-lg text-sm text-yellow-700 dark:text-yellow-400">
{t('smartPlugs.haNotConfigured')}{' '}
<span className="font-medium">{t('smartPlugs.haSettingsPath')}</span>
</div>
)}
<div>
<label className={`block text-sm text-bambu-gray mb-1 ${haConfigured ? '' : 'opacity-50'}`}>
{t('haSensors.entity')}
</label>
{entitiesError && (
<div className="p-3 mb-2 bg-red-100 dark:bg-red-500/20 rounded-lg text-sm text-red-700 dark:text-red-400">
{(entitiesError as Error).message}
</div>
)}
<div className="relative mb-2">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('haSensors.searchPlaceholder')}
disabled={!haConfigured}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div
className={`max-h-44 overflow-y-auto rounded-lg border border-bambu-dark-tertiary ${
haConfigured ? '' : 'opacity-50'
}`}
>
{!haConfigured && (
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
)}
{haConfigured && entitiesLoading && (
<div className="flex items-center gap-2 p-3 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.loading')}
</div>
)}
{haConfigured && !entitiesLoading && filtered.length === 0 && (
<div className="p-3 text-sm text-bambu-gray">{t('haSensors.noEntities')}</div>
)}
{haConfigured &&
!entitiesLoading &&
filtered.map((entity) => (
<button
key={entity.entity_id}
type="button"
onClick={() => selectEntity(entity)}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
entity.entity_id === entityId
? 'bg-bambu-green/20 text-bambu-green'
: 'text-white hover:bg-bambu-dark'
}`}
>
<div className="font-medium">{entity.friendly_name}</div>
<div className="text-xs text-bambu-gray">
{entity.entity_id}
{entity.state !== null && ` — ${entity.state}`}
{entity.unit_of_measurement ? ` ${entity.unit_of_measurement}` : ''}
</div>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.name')}</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
/>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">{t('haSensors.alertWhen')}</label>
{kind === 'binary' ? (
<select
value={alertState}
onChange={(e) => setAlertState(e.target.value as 'on' | 'off' | '')}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
>
<option value="">{t('haSensors.alertNever')}</option>
<option value="on">{stateLabel('on')}</option>
<option value="off">{stateLabel('off')}</option>
</select>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<span className="block text-xs text-bambu-gray mb-1">
{t('haSensors.alertAbove')} {unit ?? ''}
</span>
<input
type="number"
step="any"
value={alertAbove}
onChange={(e) => setAlertAbove(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
/>
</div>
<div>
<span className="block text-xs text-bambu-gray mb-1">
{t('haSensors.alertBelow')} {unit ?? ''}
</span>
<input
type="number"
step="any"
value={alertBelow}
onChange={(e) => setAlertBelow(e.target.value)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white"
/>
</div>
</div>
)}
<p className="mt-1 text-xs text-bambu-gray">{t('haSensors.alertHint')}</p>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={showOnCard}
onChange={(e) => setShowOnCard(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm text-white">{t('haSensors.showOnCard')}</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={notifyOnAlert}
onChange={(e) => setNotifyOnAlert(e.target.checked)}
disabled={!hasAlertCondition}
className="w-4 h-4"
/>
<span className={`text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
{t('haSensors.notifyOnAlert')}
</span>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={blockPrint}
onChange={(e) => setBlockPrint(e.target.checked)}
disabled={!hasAlertCondition}
className="w-4 h-4 mt-0.5"
/>
<span>
<span className={`block text-sm ${hasAlertCondition ? 'text-white' : 'text-bambu-gray'}`}>
{t('haSensors.blockPrint')}
</span>
<span className="block text-xs text-bambu-gray">{t('haSensors.blockPrintHint')}</span>
</span>
</label>
<div className="flex items-center justify-between pt-2">
{isEditing ? (
<Button type="button" variant="danger" onClick={() => deleteMutation.mutate()} disabled={isPending}>
{t('common.delete')}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button type="button" variant="secondary" onClick={onClose} disabled={isPending}>
{t('common.cancel')}
</Button>
{/* An unconfigured Home Assistant leaves nothing to bind to.
Editing an existing sensor still saves — its alert rule and
card visibility are Bambuddy's own settings and do not need
Home Assistant to be reachable to change. */}
<Button type="submit" disabled={isPending || (!haConfigured && !isEditing)}>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</Button>
</div>
</div>
</form>
</div>
</div>
);
}
+140 -21
View File
@@ -1,12 +1,13 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2 } from 'lucide-react';
import { X, ExternalLink, Box, Code2, Cog, Loader2, Layers, Check, Maximize2, Minimize2, ChevronDown } from 'lucide-react';
import { ModelViewer } from './ModelViewer';
import { GcodeViewer } from './GcodeViewer';
import { Button } from './Button';
import { api, withStreamToken } from '../api/client';
import { openInSlicer, type SlicerType } from '../utils/slicer';
import { useToast } from '../contexts/ToastContext';
import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
type ViewTab = '3d' | 'gcode';
@@ -32,14 +33,105 @@ interface Capabilities {
filament_colors: string[];
}
interface SlicerSplitButtonProps {
icon: ReactNode;
label: string;
dropdownLabel: string;
onPrimary: () => void;
items: Array<{ key: string; label: string; onClick: () => void }>;
}
// Split button: the primary part runs the default slicer action, the chevron
// opens a dropdown with the other slicer options. Outside click or Escape
// (non-propagating) closes the dropdown. The split only renders when the
// action is already possible, so there is no disabled state to express.
function SlicerSplitButton({ icon, label, dropdownLabel, onPrimary, items }: SlicerSplitButtonProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
setOpen(false);
}
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
return (
<div className="relative inline-flex" ref={containerRef}>
<div className="flex relative z-50">
<Button
variant="secondary"
size="sm"
onClick={() => {
setOpen(false);
onPrimary();
}}
className="rounded-r-none"
>
{icon}
{label}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setOpen((prev) => !prev)}
aria-label={dropdownLabel}
aria-haspopup="menu"
aria-expanded={open}
className="rounded-l-none border-l border-bambu-dark px-2"
>
<ChevronDown className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`} />
</Button>
</div>
{open && (
<div
role="menu"
className="absolute right-0 top-full mt-1 w-56 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-50 py-1"
>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
onClick={() => {
setOpen(false);
item.onClick();
}}
className="w-full text-left px-3 py-2 text-sm text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white transition-colors flex items-center gap-2"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
{item.label}
</button>
))}
</div>
)}
</div>
);
}
export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, onClose, onSliceWithBambuddy }: ModelViewerModalProps) {
const { t } = useTranslation();
const { showToast } = useToast();
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
// Desktop "Open in Slicer" target — falls back to preferred_slicer when the
// user hasn't explicitly chosen a different desktop slicer (#1329). This
// variable is only used for URI-handoff; sidecar slicing keeps using
// preferred_slicer directly.
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
const isLibrary = libraryFileId != null;
const [activeTab, setActiveTab] = useState<ViewTab | null>(null);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
@@ -280,7 +372,13 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
};
}, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
// Which file types can be handed to a desktop slicer via the URL protocol
// handler — and sliced in-app via the sidecar. Shares its list with
// `isSliceableFilename()`, which the File Manager's card menu and list row
// use, so a file's "Slice" action and its 3D-preview slicer button can no
// longer disagree about the same file.
const slicerReadyType = isSliceableFileType(fileType);
const canOpenInSlicer = isLibrary ? slicerReadyType : true;
// When the user has the in-app Slicer API enabled (Settings → Workflow →
// Slicer → Use Slicer API), library-mode previews route the header's slicer
@@ -288,39 +386,49 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
// in the file-row actions. Falls back to the external-slicer launcher when
// the API is off, when no in-app handler is wired (e.g. archive preview),
// or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
const sliceableType = (() => {
const t = (fileType || '').toLowerCase();
return t === '3mf' || t === 'stl' || t === 'step' || t === 'stp';
})();
const useBambuddySlicer = Boolean(
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && sliceableType,
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
);
const handleOpenInSlicer = async () => {
const handleOpenInSlicer = async (slicer: SlicerType) => {
if (!canOpenInSlicer) return;
const filename = title || 'model';
try {
if (isLibrary) {
const { token } = await api.createLibrarySlicerToken(libraryFileId!);
const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
openInSlicer(`${window.location.origin}${path}`, slicer);
} else {
const { token } = await api.createArchiveSlicerToken(archiveId!);
const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
openInSlicer(`${window.location.origin}${path}`, slicer);
}
} catch {
// Fallback to direct URL (works when auth is disabled)
// Fallback to direct URL (works when auth is disabled). With auth on the
// slicer may then hit a 401, so surface the failure instead of making a
// permission denial look identical to "no slicer installed".
showToast(t('modelViewer.openInSlicerFailed'), 'error');
if (isLibrary) {
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicer(downloadUrl, slicer);
} else {
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicer(downloadUrl, slicer);
}
}
};
const slicerDropdownTypes: SlicerType[] = useBambuddySlicer
? ['bambu_studio', 'orcaslicer']
: [preferredSlicer === 'orcaslicer' ? 'bambu_studio' : 'orcaslicer'];
const slicerName = (slicer: SlicerType) =>
slicer === 'orcaslicer' ? t('settings.slicerOrcaSlicer') : t('settings.slicerBambuStudio');
const slicerDropdownItems = slicerDropdownTypes.map((slicer) => ({
key: slicer,
label: t('modelViewer.openInSlicerWith', { slicer: slicerName(slicer) }),
onClick: () => handleOpenInSlicer(slicer),
}));
return (
<div
className={`fixed inset-0 bg-black/70 flex items-center justify-center z-50 ${isFullscreen ? 'p-0' : 'p-8'}`}
@@ -344,12 +452,23 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
</div>
<div className="flex items-center gap-2">
{useBambuddySlicer ? (
<Button variant="secondary" size="sm" onClick={onSliceWithBambuddy}>
<Cog className="w-4 h-4" />
{t('slice.action')}
</Button>
<SlicerSplitButton
icon={<Cog className="w-4 h-4" />}
label={t('slice.action')}
dropdownLabel={t('modelViewer.moreSlicerOptions')}
onPrimary={() => onSliceWithBambuddy?.()}
items={slicerDropdownItems}
/>
) : canOpenInSlicer ? (
<SlicerSplitButton
icon={<ExternalLink className="w-4 h-4" />}
label={t('modelViewer.openInSlicer')}
dropdownLabel={t('modelViewer.moreSlicerOptions')}
onPrimary={() => handleOpenInSlicer(preferredSlicer)}
items={slicerDropdownItems}
/>
) : (
<Button variant="secondary" size="sm" onClick={handleOpenInSlicer} disabled={!canOpenInSlicer}>
<Button variant="secondary" size="sm" disabled>
<ExternalLink className="w-4 h-4" />
{t('modelViewer.openInSlicer')}
</Button>
@@ -144,6 +144,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_ai_failure_detection && (
<span className="px-2 py-0.5 bg-fuchsia-100 dark:bg-fuchsia-500/20 text-fuchsia-700 dark:text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
)}
{provider.on_ha_sensor_alert && (
<span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300 text-xs rounded">{t('notifications.haSensorAlert')}</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-cyan-100 dark:bg-cyan-500/20 text-cyan-700 dark:text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
)}
@@ -394,6 +397,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.haSensorAlert')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.haSensorAlertDescription')}</p>
</div>
<Toggle
checked={provider.on_ha_sensor_alert ?? false}
onChange={(checked) => updateMutation.mutate({ on_ha_sensor_alert: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
<Toggle
@@ -0,0 +1,142 @@
import { useQuery } from '@tanstack/react-query';
import {
Activity,
AlertTriangle,
DoorClosed,
DoorOpen,
Droplets,
Gauge,
Lock,
LockOpen,
Thermometer,
Wind,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { api } from '../api/client';
import type { PrinterHASensorReading } from '../api/client';
/**
* The Home Assistant sensors bound to a printer, on its card (#1148, #448).
*
* Read-only by design — these are contacts and thermometers, not switches, so
* nothing here is clickable. The sibling "HA:" row above handles the entities
* you can actually operate.
*/
// Home Assistant's own device_class decides the wording, so a door reads
// "Open"/"Closed" rather than the "on"/"off" the API actually carries. Classes
// absent from this map fall through to on/off, which is what HA itself shows
// for a binary_sensor with no class.
const BINARY_LABELS: Record<string, { on: string; off: string }> = {
door: { on: 'open', off: 'closed' },
garage_door: { on: 'open', off: 'closed' },
window: { on: 'open', off: 'closed' },
opening: { on: 'open', off: 'closed' },
lock: { on: 'unlocked', off: 'locked' },
motion: { on: 'detected', off: 'clear' },
occupancy: { on: 'detected', off: 'clear' },
presence: { on: 'detected', off: 'clear' },
smoke: { on: 'detected', off: 'clear' },
gas: { on: 'detected', off: 'clear' },
moisture: { on: 'wet', off: 'dry' },
problem: { on: 'problem', off: 'ok' },
safety: { on: 'problem', off: 'ok' },
running: { on: 'running', off: 'stopped' },
};
const ICONS: Record<string, LucideIcon> = {
door: DoorOpen,
garage_door: DoorOpen,
window: DoorOpen,
opening: DoorOpen,
lock: LockOpen,
temperature: Thermometer,
humidity: Droplets,
moisture: Droplets,
motion: Activity,
occupancy: Activity,
presence: Activity,
smoke: AlertTriangle,
gas: AlertTriangle,
problem: AlertTriangle,
safety: AlertTriangle,
running: Wind,
};
function iconFor(reading: PrinterHASensorReading): LucideIcon {
const deviceClass = reading.device_class ?? '';
// A closed door wants the closed-door glyph — the map is keyed by class, so
// the two states that have a distinct "off" icon are special-cased here.
if (reading.state === 'off') {
if (ICONS[deviceClass] === DoorOpen) return DoorClosed;
if (ICONS[deviceClass] === LockOpen) return Lock;
}
return ICONS[deviceClass] ?? (reading.kind === 'numeric' ? Gauge : Activity);
}
interface Props {
printerId: number;
}
export function PrinterHASensorRow({ printerId }: Props) {
const { t } = useTranslation();
const { data: readings } = useQuery({
queryKey: ['haSensorReadings', printerId],
queryFn: () => api.getHASensorReadings(printerId),
// Served from the backend poller's cache, so this costs a local request
// and never a Home Assistant round trip. Matched to the poller's own
// cadence — refetching faster would only re-read the same reading.
refetchInterval: 15000,
});
if (!readings?.length) return null;
const describe = (reading: PrinterHASensorReading): string => {
if (!reading.reachable || reading.state === null) return t('haSensors.unavailable');
if (reading.kind === 'numeric') {
if (reading.value === null) return reading.state;
return reading.unit ? `${reading.value} ${reading.unit}` : String(reading.value);
}
const labels = BINARY_LABELS[reading.device_class ?? ''];
const key = labels ? labels[reading.state === 'on' ? 'on' : 'off'] : reading.state;
return t(`haSensors.states.${key}`, { defaultValue: key });
};
return (
<div className="flex items-center gap-2 mt-2">
<Gauge className="w-[var(--pc-i35,0.875rem)] h-[var(--pc-i35,0.875rem)] text-blue-600 dark:text-blue-400 flex-shrink-0" />
<span className="text-xs text-bambu-gray">{t('haSensors.label')}</span>
<div className="h-[2px] w-5 bg-bambu-dark-tertiary/50" />
<div className="flex flex-wrap gap-1">
{readings.map((reading) => {
const Icon = iconFor(reading);
const unreachable = !reading.reachable || reading.state === null;
return (
<span
key={reading.id}
title={
reading.block_print
? t('haSensors.blocksPrints', { entity: reading.entity_id })
: reading.entity_id
}
className={`px-2 py-0.5 text-xs rounded flex items-center gap-1 ${
unreachable
? 'bg-bambu-dark-tertiary/50 text-bambu-gray'
: reading.alerting
? 'bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400'
: 'bg-bambu-dark-tertiary text-bambu-gray'
}`}
>
<Icon className="w-[var(--pc-i25,0.625rem)] h-[var(--pc-i25,0.625rem)]" />
<span>{reading.name}</span>
<span className="font-medium">{describe(reading)}</span>
</span>
);
})}
</div>
</div>
);
}
+54 -30
View File
@@ -194,45 +194,69 @@ export function useWebSocket() {
wsRef.current = ws;
}, [processMessageQueue]);
// Throttled printer status update - coalesces rapid updates per printer.
// Write every pending printer status into the query cache.
//
// #2754: these cache writes used to happen inside a requestAnimationFrame.
// A hidden tab gets no rendering opportunities, so the browser *holds*
// queued frame callbacks rather than throttling them — every status update
// parked in a pending frame and nothing reached the query cache until the
// tab was shown again, at which point they all ran at once. That froze the
// tab-title progress (usePrintProgressTitle reads this key and nothing
// else) and stalled every other live view. The 100ms coalescing below is
// what prevented the original render cascade; the frame callback only ever
// deferred the write by a frame, so it is gone.
// Extracted so the hidden-tab path below can run it inline: both paths share
// this one body, so the merge semantics cannot drift apart. Cancels any
// scheduled coalescing timer, since everything it was going to write has
// just been written and re-running it would re-apply stale data over newer.
const flushPrinterStatus = useCallback(() => {
if (printerStatusTimeoutRef.current) {
clearTimeout(printerStatusTimeoutRef.current);
printerStatusTimeoutRef.current = null;
}
const updates = new Map(pendingPrinterStatus.current);
pendingPrinterStatus.current.clear();
updates.forEach((statusData, id) => {
queryClient.setQueryData(['printerStatus', id], (old: Record<string, unknown> | undefined) => {
const merged = { ...old, ...statusData };
if (merged.wifi_signal == null && old?.wifi_signal != null) {
merged.wifi_signal = old.wifi_signal;
}
return merged;
});
});
}, [queryClient]);
// Printer status update — coalesced while the tab is visible, written
// straight through while it is not.
//
// #2754 (reporter @mic4rd), in two stages. First, these writes ran inside a
// requestAnimationFrame: a hidden tab gets no rendering opportunities, so
// the browser *holds* queued frame callbacks rather than throttling them,
// and nothing reached the cache until the tab was shown again. Removing the
// frame callback fixed that total stall but not the report, because a second
// timer-shaped dependency was left behind — this 100ms coalescing window.
//
// Browsers clamp timers in a hidden page to at best once a second, and drop
// pages hidden for more than five minutes to roughly one wake-up a minute.
// The reporter saw a tab title stuck at 2% beside a page at 40%.
//
// The coalescing exists to stop rapid messages triggering a render cascade.
// A hidden tab is not painting, so there is no cascade to prevent there —
// the timer is pure cost, and it is exactly the thing being throttled. So
// when hidden, skip it and write immediately.
//
// Note "hidden", not "unfocused": on Windows a fully-occluded window reports
// visibilityState 'hidden' too, which is why the reporter saw this from
// merely clicking away rather than only from switching tabs.
const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
// Merge with any pending data for this printer
const existing = pendingPrinterStatus.current.get(printerId) || {};
pendingPrinterStatus.current.set(printerId, { ...existing, ...data });
if (document.hidden) {
flushPrinterStatus();
return;
}
// Schedule update if not already scheduled
if (!printerStatusTimeoutRef.current) {
printerStatusTimeoutRef.current = window.setTimeout(() => {
const updates = new Map(pendingPrinterStatus.current);
pendingPrinterStatus.current.clear();
printerStatusTimeoutRef.current = null;
// Apply all pending updates
updates.forEach((statusData, id) => {
queryClient.setQueryData(
['printerStatus', id],
(old: Record<string, unknown> | undefined) => {
const merged = { ...old, ...statusData };
if (merged.wifi_signal == null && old?.wifi_signal != null) {
merged.wifi_signal = old.wifi_signal;
}
return merged;
}
);
});
}, 100); // Update at most every 100ms
printerStatusTimeoutRef.current = window.setTimeout(flushPrinterStatus, 100);
}
}, [queryClient]);
}, [flushPrinterStatus]);
// Debounced invalidation helper - coalesces multiple rapid invalidations
const debouncedInvalidate = useCallback((queryKey: string) => {
+61
View File
@@ -3820,6 +3820,7 @@ export default {
scanFolder: 'Scannen',
toast: {
folderCreated: 'Ordner erstellt',
openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
folderDeleted: 'Ordner gelöscht',
fileDeleted: 'Datei gelöscht',
filesDeleted: '{{count}} Dateien gelöscht',
@@ -5426,6 +5427,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Im Slicer öffnen',
openInSlicerWith: 'In {{slicer}} öffnen',
moreSlicerOptions: 'Weitere Slicer-Optionen',
openInSlicerFailed: 'Konnte nicht im Slicer öffnen',
tabs: {
model: '3D-Modell',
gcode: 'G-Code Vorschau',
@@ -5476,6 +5480,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensoren',
unavailable: 'Nicht verfügbar',
blocksPrints: '{{entity}} — hält Drucke zurück, solange der Alarm aktiv ist',
states: {
open: 'Offen',
closed: 'Geschlossen',
unlocked: 'Entriegelt',
locked: 'Verriegelt',
detected: 'Erkannt',
clear: 'Frei',
wet: 'Nass',
dry: 'Trocken',
problem: 'Problem',
ok: 'OK',
running: 'Läuft',
stopped: 'Gestoppt',
on: 'An',
off: 'Aus',
},
sectionTitle: 'Home-Assistant-Sensoren',
add: 'Sensor hinzufügen',
addTitle: 'Home-Assistant-Sensor hinzufügen',
editTitle: 'Home-Assistant-Sensor bearbeiten',
empty: 'Noch keine Sensoren. Verknüpfe einen Türkontakt oder ein Thermometer aus Home Assistant, um es auf der Druckerkarte anzuzeigen.',
unknownPrinter: 'Unbekannter Drucker',
badgeBlocks: 'Hält Drucke zurück',
badgeNotifies: 'Benachrichtigt',
badgeHidden: 'Auf Karte ausgeblendet',
printer: 'Drucker',
entity: 'Entität',
searchPlaceholder: 'Entitäten suchen ...',
noEntities: 'Keine passenden Entitäten',
name: 'Anzeigename',
alertWhen: 'Alarm wenn',
alertNever: 'Nie — nur anzeigen',
alertAbove: 'Über',
alertBelow: 'Unter',
alertHint: 'Der Alarmzustand hebt den Sensor auf der Druckerkarte hervor und aktiviert die Optionen unten.',
showOnCard: 'Auf Druckerkarte anzeigen',
notifyOnAlert: 'Benachrichtigen, sobald der Alarm ausgelöst wird',
blockPrint: 'Wartende Drucke zurückhalten, solange der Alarm aktiv ist',
blockPrintHint: 'Aufträge bleiben in der Warteschlange und starten von selbst, sobald der Sensor wieder frei ist. Wird ignoriert, solange Home Assistant nicht erreichbar ist.',
toast: {
created: 'Sensor hinzugefügt',
updated: 'Sensor gespeichert',
deleted: 'Sensor entfernt',
},
error: {
pickEntity: 'Wähle eine Home-Assistant-Entität',
nameRequired: 'Gib einen Anzeigenamen ein',
printerRequired: 'Wähle einen Drucker',
alertRequired: 'Lege zuerst eine Alarmbedingung fest',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Admin',
@@ -5788,6 +5847,8 @@ export default {
notificationEvents: 'Benachrichtigungsereignisse',
progressPercent: '(25 %, 50 %, 75 %)',
bedCooledAfterPrint: '(nach Druckabschluss)',
haSensorAlert: 'Sensor-Alarm',
haSensorAlertDescription: '(ein verknüpfter Home-Assistant-Sensor braucht Aufmerksamkeit)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy-Priorität',
+61
View File
@@ -3849,6 +3849,7 @@ export default {
scanFolder: 'Scan',
toast: {
folderCreated: 'Folder created',
openInSlicerFailed: 'Could not open in slicer',
folderDeleted: 'Folder deleted',
fileDeleted: 'File deleted',
filesDeleted: 'Deleted {{count}} files',
@@ -5475,6 +5476,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Open in Slicer',
openInSlicerWith: 'Open in {{slicer}}',
moreSlicerOptions: 'More slicer options',
openInSlicerFailed: 'Could not open in slicer',
tabs: {
model: '3D Model',
gcode: 'G-code Preview',
@@ -5525,6 +5529,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensors',
unavailable: 'Unavailable',
blocksPrints: '{{entity}} — holds prints while alerting',
states: {
open: 'Open',
closed: 'Closed',
unlocked: 'Unlocked',
locked: 'Locked',
detected: 'Detected',
clear: 'Clear',
wet: 'Wet',
dry: 'Dry',
problem: 'Problem',
ok: 'OK',
running: 'Running',
stopped: 'Stopped',
on: 'On',
off: 'Off',
},
sectionTitle: 'Home Assistant Sensors',
add: 'Add Sensor',
addTitle: 'Add Home Assistant Sensor',
editTitle: 'Edit Home Assistant Sensor',
empty: 'No sensors yet. Bind a door contact or a thermometer from Home Assistant to show it on a printer card.',
unknownPrinter: 'Unknown printer',
badgeBlocks: 'Holds prints',
badgeNotifies: 'Notifies',
badgeHidden: 'Hidden on card',
printer: 'Printer',
entity: 'Entity',
searchPlaceholder: 'Search entities...',
noEntities: 'No matching entities',
name: 'Display name',
alertWhen: 'Alert when',
alertNever: 'Never — display only',
alertAbove: 'Above',
alertBelow: 'Below',
alertHint: 'The alert state highlights the sensor on the printer card and enables the options below.',
showOnCard: 'Show on printer card',
notifyOnAlert: 'Send a notification when it starts alerting',
blockPrint: 'Hold queued prints while alerting',
blockPrintHint: 'Jobs stay queued and start on their own once the sensor clears. Ignored while Home Assistant is unreachable.',
toast: {
created: 'Sensor added',
updated: 'Sensor saved',
deleted: 'Sensor removed',
},
error: {
pickEntity: 'Pick a Home Assistant entity',
nameRequired: 'Enter a display name',
printerRequired: 'Pick a printer',
alertRequired: 'Set an alert condition first',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Admin',
@@ -5837,6 +5896,8 @@ export default {
notificationEvents: 'Notification Events',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(after print completes)',
haSensorAlert: 'Sensor Alert',
haSensorAlertDescription: '(a bound Home Assistant sensor needs attention)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy Priority',
+61
View File
@@ -3823,6 +3823,7 @@ export default {
scanFolder: 'Escanear',
toast: {
folderCreated: 'Carpeta creada',
openInSlicerFailed: 'No se pudo abrir en el laminador',
folderDeleted: 'Carpeta eliminada',
fileDeleted: 'Archivo eliminado',
filesDeleted: 'Se eliminaron {{count}} archivos',
@@ -5435,6 +5436,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Abrir en el laminador',
openInSlicerWith: 'Abrir en {{slicer}}',
moreSlicerOptions: 'Más opciones de laminador',
openInSlicerFailed: 'No se pudo abrir en el laminador',
tabs: {
model: 'Modelo 3D',
gcode: 'Vista previa de G-code',
@@ -5485,6 +5489,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensores',
unavailable: 'No disponible',
blocksPrints: '{{entity}} — retiene las impresiones mientras haya alerta',
states: {
open: 'Abierto',
closed: 'Cerrado',
unlocked: 'Desbloqueado',
locked: 'Bloqueado',
detected: 'Detectado',
clear: 'Despejado',
wet: 'Húmedo',
dry: 'Seco',
problem: 'Problema',
ok: 'OK',
running: 'En marcha',
stopped: 'Detenido',
on: 'Encendido',
off: 'Apagado',
},
sectionTitle: 'Sensores de Home Assistant',
add: 'Añadir sensor',
addTitle: 'Añadir sensor de Home Assistant',
editTitle: 'Editar sensor de Home Assistant',
empty: 'Aún no hay sensores. Vincula un contacto de puerta o un termómetro de Home Assistant para mostrarlo en la tarjeta de la impresora.',
unknownPrinter: 'Impresora desconocida',
badgeBlocks: 'Retiene impresiones',
badgeNotifies: 'Notifica',
badgeHidden: 'Oculto en la tarjeta',
printer: 'Impresora',
entity: 'Entidad',
searchPlaceholder: 'Buscar entidades...',
noEntities: 'No hay entidades coincidentes',
name: 'Nombre visible',
alertWhen: 'Alertar cuando',
alertNever: 'Nunca — solo mostrar',
alertAbove: 'Por encima de',
alertBelow: 'Por debajo de',
alertHint: 'El estado de alerta resalta el sensor en la tarjeta de la impresora y habilita las opciones de abajo.',
showOnCard: 'Mostrar en la tarjeta de la impresora',
notifyOnAlert: 'Enviar una notificación cuando empiece la alerta',
blockPrint: 'Retener las impresiones en cola mientras haya alerta',
blockPrintHint: 'Los trabajos siguen en cola y arrancan solos cuando el sensor se despeja. Se ignora mientras Home Assistant no esté accesible.',
toast: {
created: 'Sensor añadido',
updated: 'Sensor guardado',
deleted: 'Sensor eliminado',
},
error: {
pickEntity: 'Elige una entidad de Home Assistant',
nameRequired: 'Introduce un nombre visible',
printerRequired: 'Elige una impresora',
alertRequired: 'Define primero una condición de alerta',
},
},
smartPlugs: {
offline: 'Desconectado',
admin: 'Administración',
@@ -5797,6 +5856,8 @@ export default {
notificationEvents: 'Eventos de notificación',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(después de completar la impresión)',
haSensorAlert: 'Alerta de sensor',
haSensorAlertDescription: '(un sensor de Home Assistant vinculado requiere atención)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Prioridad de ntfy',
+61
View File
@@ -3809,6 +3809,7 @@ export default {
scanFolder: 'Scanner',
toast: {
folderCreated: 'Dossier créé',
openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
folderDeleted: 'Dossier supprimé',
fileDeleted: 'Fichier supprimé',
filesDeleted: '{{count}} fichiers supprimés',
@@ -5416,6 +5417,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Ouvrir dans le Slicer',
openInSlicerWith: 'Ouvrir dans {{slicer}}',
moreSlicerOptions: "Plus d'options de slicer",
openInSlicerFailed: "Impossible d'ouvrir dans le slicer",
tabs: {
model: 'Modèle 3D',
gcode: 'Aperçu G-code',
@@ -5466,6 +5470,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Capteurs',
unavailable: 'Indisponible',
blocksPrints: '{{entity}} — retient les impressions tant que l\'alerte est active',
states: {
open: 'Ouvert',
closed: 'Fermé',
unlocked: 'Déverrouillé',
locked: 'Verrouillé',
detected: 'Détecté',
clear: 'Dégagé',
wet: 'Humide',
dry: 'Sec',
problem: 'Problème',
ok: 'OK',
running: 'En marche',
stopped: 'Arrêté',
on: 'Activé',
off: 'Désactivé',
},
sectionTitle: 'Capteurs Home Assistant',
add: 'Ajouter un capteur',
addTitle: 'Ajouter un capteur Home Assistant',
editTitle: 'Modifier le capteur Home Assistant',
empty: 'Aucun capteur pour l\'instant. Liez un contact de porte ou un thermomètre depuis Home Assistant pour l\'afficher sur la carte imprimante.',
unknownPrinter: 'Imprimante inconnue',
badgeBlocks: 'Retient les impressions',
badgeNotifies: 'Notifie',
badgeHidden: 'Masqué sur la carte',
printer: 'Imprimante',
entity: 'Entité',
searchPlaceholder: 'Rechercher des entités...',
noEntities: 'Aucune entité correspondante',
name: 'Nom affiché',
alertWhen: 'Alerter quand',
alertNever: 'Jamais — affichage seul',
alertAbove: 'Au-dessus de',
alertBelow: 'En dessous de',
alertHint: 'L\'état d\'alerte met le capteur en évidence sur la carte imprimante et active les options ci-dessous.',
showOnCard: 'Afficher sur la carte imprimante',
notifyOnAlert: 'Envoyer une notification au déclenchement de l\'alerte',
blockPrint: 'Retenir les impressions en file tant que l\'alerte est active',
blockPrintHint: 'Les travaux restent en file et démarrent d\'eux-mêmes une fois le capteur revenu à la normale. Ignoré tant que Home Assistant est injoignable.',
toast: {
created: 'Capteur ajouté',
updated: 'Capteur enregistré',
deleted: 'Capteur supprimé',
},
error: {
pickEntity: 'Choisissez une entité Home Assistant',
nameRequired: 'Saisissez un nom affiché',
printerRequired: 'Choisissez une imprimante',
alertRequired: 'Définissez d\'abord une condition d\'alerte',
},
},
smartPlugs: {
offline: 'Hors ligne',
admin: 'Administrateur',
@@ -5778,6 +5837,8 @@ export default {
notificationEvents: 'Événements de notification',
progressPercent: '(25 %, 50 %, 75 %)',
bedCooledAfterPrint: '(après la fin de l\'impression)',
haSensorAlert: 'Alerte de capteur',
haSensorAlertDescription: '(un capteur Home Assistant lié nécessite votre attention)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Priorité ntfy',
+61
View File
@@ -3808,6 +3808,7 @@ export default {
scanFolder: 'Scansiona',
toast: {
folderCreated: 'Cartella creata',
openInSlicerFailed: 'Impossibile aprire nello slicer',
folderDeleted: 'Cartella eliminata',
fileDeleted: 'File eliminato',
filesDeleted: 'Eliminati {{count}} file',
@@ -5415,6 +5416,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Apri nello slicer',
openInSlicerWith: 'Apri in {{slicer}}',
moreSlicerOptions: 'Altre opzioni dello slicer',
openInSlicerFailed: 'Impossibile aprire nello slicer',
tabs: {
model: 'Modello 3D',
gcode: 'Anteprima G-code',
@@ -5465,6 +5469,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensori',
unavailable: 'Non disponibile',
blocksPrints: '{{entity}} — trattiene le stampe finché l\'allarme è attivo',
states: {
open: 'Aperto',
closed: 'Chiuso',
unlocked: 'Sbloccato',
locked: 'Bloccato',
detected: 'Rilevato',
clear: 'Libero',
wet: 'Bagnato',
dry: 'Asciutto',
problem: 'Problema',
ok: 'OK',
running: 'In funzione',
stopped: 'Fermo',
on: 'Acceso',
off: 'Spento',
},
sectionTitle: 'Sensori Home Assistant',
add: 'Aggiungi sensore',
addTitle: 'Aggiungi sensore Home Assistant',
editTitle: 'Modifica sensore Home Assistant',
empty: 'Nessun sensore. Collega un contatto porta o un termometro da Home Assistant per mostrarlo sulla scheda stampante.',
unknownPrinter: 'Stampante sconosciuta',
badgeBlocks: 'Trattiene le stampe',
badgeNotifies: 'Notifica',
badgeHidden: 'Nascosto sulla scheda',
printer: 'Stampante',
entity: 'Entità',
searchPlaceholder: 'Cerca entità...',
noEntities: 'Nessuna entità corrispondente',
name: 'Nome visualizzato',
alertWhen: 'Allarme quando',
alertNever: 'Mai — solo visualizzazione',
alertAbove: 'Sopra',
alertBelow: 'Sotto',
alertHint: 'Lo stato di allarme evidenzia il sensore sulla scheda stampante e abilita le opzioni sottostanti.',
showOnCard: 'Mostra sulla scheda stampante',
notifyOnAlert: 'Invia una notifica quando scatta l\'allarme',
blockPrint: 'Trattieni le stampe in coda finché l\'allarme è attivo',
blockPrintHint: 'I lavori restano in coda e partono da soli quando il sensore rientra. Ignorato finché Home Assistant non è raggiungibile.',
toast: {
created: 'Sensore aggiunto',
updated: 'Sensore salvato',
deleted: 'Sensore rimosso',
},
error: {
pickEntity: 'Scegli un\'entità Home Assistant',
nameRequired: 'Inserisci un nome visualizzato',
printerRequired: 'Scegli una stampante',
alertRequired: 'Imposta prima una condizione di allarme',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Amministrazione',
@@ -5777,6 +5836,8 @@ export default {
notificationEvents: 'Eventi di notifica',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(dopo il completamento della stampa)',
haSensorAlert: 'Avviso sensore',
haSensorAlertDescription: '(un sensore Home Assistant collegato richiede attenzione)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Priorità ntfy',
+61
View File
@@ -3820,6 +3820,7 @@ export default {
scanFolder: 'スキャン',
toast: {
folderCreated: 'フォルダを作成しました',
openInSlicerFailed: 'スライサーで開けませんでした',
folderDeleted: 'フォルダを削除しました',
fileDeleted: 'ファイルを削除しました',
filesDeleted: '{{count}}件のファイルを削除しました',
@@ -5427,6 +5428,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'スライサーで開く',
openInSlicerWith: '{{slicer}}で開く',
moreSlicerOptions: 'その他のスライサーオプション',
openInSlicerFailed: 'スライサーで開けませんでした',
tabs: {
model: '3Dモデル',
gcode: 'G-codeプレビュー',
@@ -5477,6 +5481,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'センサー',
unavailable: '利用不可',
blocksPrints: '{{entity}} — アラート中は印刷を保留します',
states: {
open: '開',
closed: '閉',
unlocked: '解錠',
locked: '施錠',
detected: '検知',
clear: 'なし',
wet: '濡れ',
dry: '乾燥',
problem: '異常',
ok: '正常',
running: '作動中',
stopped: '停止',
on: 'オン',
off: 'オフ',
},
sectionTitle: 'Home Assistant センサー',
add: 'センサーを追加',
addTitle: 'Home Assistant センサーを追加',
editTitle: 'Home Assistant センサーを編集',
empty: 'センサーがまだありません。Home Assistant のドアセンサーや温度計を連携すると、プリンターカードに表示されます。',
unknownPrinter: '不明なプリンター',
badgeBlocks: '印刷を保留',
badgeNotifies: '通知する',
badgeHidden: 'カードに非表示',
printer: 'プリンター',
entity: 'エンティティ',
searchPlaceholder: 'エンティティを検索...',
noEntities: '一致するエンティティがありません',
name: '表示名',
alertWhen: 'アラート条件',
alertNever: 'なし — 表示のみ',
alertAbove: 'しきい値超過',
alertBelow: 'しきい値未満',
alertHint: 'アラート状態になるとプリンターカードで強調表示され、下のオプションが有効になります。',
showOnCard: 'プリンターカードに表示',
notifyOnAlert: 'アラート発生時に通知を送信',
blockPrint: 'アラート中はキューの印刷を保留',
blockPrintHint: 'ジョブはキューに残り、センサーが解除されると自動的に開始します。Home Assistant に接続できない間は無視されます。',
toast: {
created: 'センサーを追加しました',
updated: 'センサーを保存しました',
deleted: 'センサーを削除しました',
},
error: {
pickEntity: 'Home Assistant のエンティティを選択してください',
nameRequired: '表示名を入力してください',
printerRequired: 'プリンターを選択してください',
alertRequired: '先にアラート条件を設定してください',
},
},
smartPlugs: {
offline: 'オフライン',
admin: '管理',
@@ -5789,6 +5848,8 @@ export default {
notificationEvents: '通知イベント',
progressPercent: '(25%、50%、75%)',
bedCooledAfterPrint: '(印刷完了後)',
haSensorAlert: 'センサーアラート',
haSensorAlertDescription: '(連携した Home Assistant センサーに注意が必要です)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 優先度',
+61
View File
@@ -3631,6 +3631,7 @@ export default {
scanFolder: '스캔',
toast: {
folderCreated: '폴더 생성됨',
openInSlicerFailed: '슬라이서에서 열 수 없습니다',
folderDeleted: '폴더 삭제됨',
fileDeleted: '파일 삭제됨',
filesDeleted: '{{count}}개 파일 삭제됨',
@@ -5162,6 +5163,9 @@ export default {
},
modelViewer: {
openInSlicer: '슬라이서에서 열기',
openInSlicerWith: '{{slicer}}에서 열기',
moreSlicerOptions: '슬라이서 옵션 더 보기',
openInSlicerFailed: '슬라이서에서 열 수 없습니다',
tabs: {
model: '3D 모델',
gcode: 'G-code 미리보기'
@@ -5208,6 +5212,61 @@ export default {
replaceCarbonFilter: '활성탄 필터 교체',
lubricateLeftNozzleRail: '왼쪽 노즐 레일 윤활 (H2 시리즈)'
},
haSensors: {
label: '센서',
unavailable: '사용 불가',
blocksPrints: '{{entity}} — 경고 중에는 인쇄를 보류합니다',
states: {
open: '열림',
closed: '닫힘',
unlocked: '잠금 해제',
locked: '잠김',
detected: '감지됨',
clear: '없음',
wet: '젖음',
dry: '건조',
problem: '문제',
ok: '정상',
running: '작동 중',
stopped: '정지',
on: '켜짐',
off: '꺼짐',
},
sectionTitle: 'Home Assistant 센서',
add: '센서 추가',
addTitle: 'Home Assistant 센서 추가',
editTitle: 'Home Assistant 센서 편집',
empty: '아직 센서가 없습니다. Home Assistant의 도어 센서나 온도계를 연결하면 프린터 카드에 표시됩니다.',
unknownPrinter: '알 수 없는 프린터',
badgeBlocks: '인쇄 보류',
badgeNotifies: '알림',
badgeHidden: '카드에서 숨김',
printer: '프린터',
entity: '엔터티',
searchPlaceholder: '엔터티 검색...',
noEntities: '일치하는 엔터티 없음',
name: '표시 이름',
alertWhen: '경고 조건',
alertNever: '없음 — 표시만',
alertAbove: '초과',
alertBelow: '미만',
alertHint: '경고 상태가 되면 프린터 카드에서 강조되고 아래 옵션이 활성화됩니다.',
showOnCard: '프린터 카드에 표시',
notifyOnAlert: '경고가 시작되면 알림 보내기',
blockPrint: '경고 중에는 대기 중인 인쇄를 보류',
blockPrintHint: '작업은 대기열에 남아 있다가 센서가 해제되면 자동으로 시작됩니다. Home Assistant에 연결할 수 없는 동안에는 무시됩니다.',
toast: {
created: '센서를 추가했습니다',
updated: '센서를 저장했습니다',
deleted: '센서를 삭제했습니다',
},
error: {
pickEntity: 'Home Assistant 엔터티를 선택하세요',
nameRequired: '표시 이름을 입력하세요',
printerRequired: '프린터를 선택하세요',
alertRequired: '먼저 경고 조건을 설정하세요',
},
},
smartPlugs: {
offline: '오프라인',
admin: '관리자',
@@ -5503,6 +5562,8 @@ export default {
notificationEvents: '알림 이벤트',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(인쇄 완료 후)',
haSensorAlert: '센서 경고',
haSensorAlertDescription: '(연결된 Home Assistant 센서에 주의가 필요합니다)',
eventPriority: {
sectionTitle: 'ntfy 우선순위',
helpNtfy: '각 활성화된 이벤트에 대한 우선순위를 선택하세요. ntfy는 이를 사용하여 알림을 에스컬레이션합니다(소리, 가시성, 푸시 동작). 여기서 설정되지 않은 수준은 ntfy 서버 기본값을 사용합니다.',
+61
View File
@@ -3808,6 +3808,7 @@ export default {
scanFolder: 'Escanear',
toast: {
folderCreated: 'Pasta criada',
openInSlicerFailed: 'Não foi possível abrir no fatiador',
folderDeleted: 'Pasta excluída',
fileDeleted: 'Arquivo excluído',
filesDeleted: 'Excluídos {{count}} arquivos',
@@ -5415,6 +5416,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: 'Abrir no Slicer',
openInSlicerWith: 'Abrir em {{slicer}}',
moreSlicerOptions: 'Mais opções de fatiador',
openInSlicerFailed: 'Não foi possível abrir no fatiador',
tabs: {
model: 'Modelo 3D',
gcode: 'Pré-visualização G-code',
@@ -5465,6 +5469,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: 'Sensores',
unavailable: 'Indisponível',
blocksPrints: '{{entity}} — segura as impressões enquanto houver alerta',
states: {
open: 'Aberto',
closed: 'Fechado',
unlocked: 'Destrancado',
locked: 'Trancado',
detected: 'Detectado',
clear: 'Livre',
wet: 'Molhado',
dry: 'Seco',
problem: 'Problema',
ok: 'OK',
running: 'Em funcionamento',
stopped: 'Parado',
on: 'Ligado',
off: 'Desligado',
},
sectionTitle: 'Sensores do Home Assistant',
add: 'Adicionar sensor',
addTitle: 'Adicionar sensor do Home Assistant',
editTitle: 'Editar sensor do Home Assistant',
empty: 'Nenhum sensor ainda. Vincule um contato de porta ou um termômetro do Home Assistant para exibi-lo no cartão da impressora.',
unknownPrinter: 'Impressora desconhecida',
badgeBlocks: 'Segura impressões',
badgeNotifies: 'Notifica',
badgeHidden: 'Oculto no cartão',
printer: 'Impressora',
entity: 'Entidade',
searchPlaceholder: 'Buscar entidades...',
noEntities: 'Nenhuma entidade correspondente',
name: 'Nome exibido',
alertWhen: 'Alertar quando',
alertNever: 'Nunca — apenas exibir',
alertAbove: 'Acima de',
alertBelow: 'Abaixo de',
alertHint: 'O estado de alerta destaca o sensor no cartão da impressora e habilita as opções abaixo.',
showOnCard: 'Mostrar no cartão da impressora',
notifyOnAlert: 'Enviar uma notificação quando o alerta começar',
blockPrint: 'Segurar as impressões na fila enquanto houver alerta',
blockPrintHint: 'Os trabalhos permanecem na fila e começam sozinhos quando o sensor normaliza. Ignorado enquanto o Home Assistant estiver inacessível.',
toast: {
created: 'Sensor adicionado',
updated: 'Sensor salvo',
deleted: 'Sensor removido',
},
error: {
pickEntity: 'Escolha uma entidade do Home Assistant',
nameRequired: 'Informe um nome exibido',
printerRequired: 'Escolha uma impressora',
alertRequired: 'Defina primeiro uma condição de alerta',
},
},
smartPlugs: {
offline: 'Offline',
admin: 'Administrador',
@@ -5777,6 +5836,8 @@ export default {
notificationEvents: 'Eventos de Notificação',
progressPercent: '(25%, 50%, 75%)',
bedCooledAfterPrint: '(após conclusão da impressão)',
haSensorAlert: 'Alerta de sensor',
haSensorAlertDescription: '(um sensor do Home Assistant vinculado precisa de atenção)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'Prioridade ntfy',
+61
View File
@@ -3623,6 +3623,7 @@ export default {
scanFolder: "Сканировать",
toast: {
folderCreated: "Папка создана",
openInSlicerFailed: "Не удалось открыть в слайсере",
folderDeleted: "Папка удалена",
fileDeleted: "Файл удалён",
filesDeleted: "Удалено файлов: {{count}}",
@@ -5150,6 +5151,9 @@ export default {
},
modelViewer: {
openInSlicer: "Открыть в слайсере",
openInSlicerWith: "Открыть в {{slicer}}",
moreSlicerOptions: "Другие варианты слайсера",
openInSlicerFailed: "Не удалось открыть в слайсере",
tabs: {
model: "3D-модель",
gcode: "Предпросмотр G-code",
@@ -5196,6 +5200,61 @@ export default {
replaceCarbonFilter: "Заменить угольный фильтр",
lubricateLeftNozzleRail: "Смазать направляющую левого сопла (серия H2)",
},
haSensors: {
label: "Датчики",
unavailable: "Недоступно",
blocksPrints: "{{entity}} — удерживает печать, пока активно оповещение",
states: {
open: "Открыто",
closed: "Закрыто",
unlocked: "Разблокировано",
locked: "Заблокировано",
detected: "Обнаружено",
clear: "Чисто",
wet: "Влажно",
dry: "Сухо",
problem: "Проблема",
ok: "Норма",
running: "Работает",
stopped: "Остановлено",
on: "Вкл",
off: "Выкл",
},
sectionTitle: "Датчики Home Assistant",
add: "Добавить датчик",
addTitle: "Добавить датчик Home Assistant",
editTitle: "Изменить датчик Home Assistant",
empty: "Датчиков пока нет. Свяжите дверной контакт или термометр из Home Assistant, чтобы показать его на карточке принтера.",
unknownPrinter: "Неизвестный принтер",
badgeBlocks: "Удерживает печать",
badgeNotifies: "Уведомляет",
badgeHidden: "Скрыт на карточке",
printer: "Принтер",
entity: "Сущность",
searchPlaceholder: "Поиск сущностей...",
noEntities: "Подходящих сущностей нет",
name: "Отображаемое имя",
alertWhen: "Оповещать когда",
alertNever: "Никогда — только показывать",
alertAbove: "Выше",
alertBelow: "Ниже",
alertHint: "Состояние оповещения подсвечивает датчик на карточке принтера и включает параметры ниже.",
showOnCard: "Показывать в карточке принтера",
notifyOnAlert: "Отправлять уведомление при срабатывании",
blockPrint: "Удерживать печать в очереди, пока активно оповещение",
blockPrintHint: "Задания остаются в очереди и запускаются сами, когда датчик приходит в норму. Игнорируется, пока Home Assistant недоступен.",
toast: {
created: "Датчик добавлен",
updated: "Датчик сохранён",
deleted: "Датчик удалён",
},
error: {
pickEntity: "Выберите сущность Home Assistant",
nameRequired: "Введите отображаемое имя",
printerRequired: "Выберите принтер",
alertRequired: "Сначала задайте условие оповещения",
},
},
smartPlugs: {
offline: "Не в сети",
admin: "Управление",
@@ -5490,6 +5549,8 @@ export default {
notificationEvents: "События для уведомлений",
progressPercent: "(25%, 50%, 75%)",
bedCooledAfterPrint: "(после завершения печати)",
haSensorAlert: "Оповещение датчика",
haSensorAlertDescription: "(связанный датчик Home Assistant требует внимания)",
eventPriority: {
sectionTitle: "Приоритет ntfy",
helpNtfy: "Выберите приоритет для каждого включённого события. ntfy использует его для усиления оповещений: звука, видимости и поведения push-уведомлений. Для неуказанных событий используется приоритет по умолчанию сервера ntfy.",
+61
View File
@@ -3816,6 +3816,7 @@ export default {
scanFolder: 'Tara',
toast: {
folderCreated: 'Klasör oluşturuldu',
openInSlicerFailed: 'Dilimleyicide açılamadı',
folderDeleted: 'Klasör silindi',
fileDeleted: 'Dosya silindi',
filesDeleted: '{{count}} dosya silindi',
@@ -5391,6 +5392,9 @@ export default {
// Model Görüntüleyici
modelViewer: {
openInSlicer: 'Dilimleyicide Aç',
openInSlicerWith: '{{slicer}} ile aç',
moreSlicerOptions: 'Diğer dilimleyici seçenekleri',
openInSlicerFailed: 'Dilimleyicide açılamadı',
tabs: {
model: '3B Model',
gcode: 'G-kod Önizleme',
@@ -5441,6 +5445,61 @@ export default {
},
// Akıllı Prizler
haSensors: {
label: 'Sensörler',
unavailable: 'Kullanılamıyor',
blocksPrints: '{{entity}} — uyarı sürerken baskıları bekletir',
states: {
open: 'Açık',
closed: 'Kapalı',
unlocked: 'Kilit açık',
locked: 'Kilitli',
detected: 'Algılandı',
clear: 'Temiz',
wet: 'Islak',
dry: 'Kuru',
problem: 'Sorun',
ok: 'Normal',
running: 'Çalışıyor',
stopped: 'Durdu',
on: 'Açık',
off: 'Kapalı',
},
sectionTitle: 'Home Assistant Sensörleri',
add: 'Sensör ekle',
addTitle: 'Home Assistant sensörü ekle',
editTitle: 'Home Assistant sensörünü düzenle',
empty: 'Henüz sensör yok. Yazıcı kartında göstermek için Home Assistant\'tan bir kapı kontağı veya termometre bağlayın.',
unknownPrinter: 'Bilinmeyen yazıcı',
badgeBlocks: 'Baskıları bekletir',
badgeNotifies: 'Bildirir',
badgeHidden: 'Kartta gizli',
printer: 'Yazıcı',
entity: 'Varlık',
searchPlaceholder: 'Varlık ara...',
noEntities: 'Eşleşen varlık yok',
name: 'Görünen ad',
alertWhen: 'Şu durumda uyar',
alertNever: 'Asla — yalnızca göster',
alertAbove: 'Şunun üstünde',
alertBelow: 'Şunun altında',
alertHint: 'Uyarı durumu sensörü yazıcı kartında vurgular ve aşağıdaki seçenekleri etkinleştirir.',
showOnCard: 'Yazıcı kartında göster',
notifyOnAlert: 'Uyarı başladığında bildirim gönder',
blockPrint: 'Uyarı sürerken kuyruktaki baskıları beklet',
blockPrintHint: 'İşler kuyrukta kalır ve sensör normale döndüğünde kendiliğinden başlar. Home Assistant\'a ulaşılamadığı sürece yok sayılır.',
toast: {
created: 'Sensör eklendi',
updated: 'Sensör kaydedildi',
deleted: 'Sensör kaldırıldı',
},
error: {
pickEntity: 'Bir Home Assistant varlığı seçin',
nameRequired: 'Bir görünen ad girin',
printerRequired: 'Bir yazıcı seçin',
alertRequired: 'Önce bir uyarı koşulu belirleyin',
},
},
smartPlugs: {
offline: 'Çevrimdışı',
admin: 'Yönetici',
@@ -5739,6 +5798,8 @@ export default {
notificationEvents: 'Bildirim Olayları',
progressPercent: '(%25, %50, %75)',
bedCooledAfterPrint: '(baskı tamamlandıktan sonra)',
haSensorAlert: 'Sensör Uyarısı',
haSensorAlertDescription: '(bağlı bir Home Assistant sensörü dikkat gerektiriyor)',
eventPriority: {
sectionTitle: 'ntfy Önceliği',
helpNtfy: 'Her etkin olay için bir öncelik seçin. ntfy uyarıları kademelendirmek için bunları kullanır (ses, görünürlük, push davranışı). Burada ayarlanmamış seviyeler ntfy sunucu varsayılanını kullanır.',
+61
View File
@@ -3849,6 +3849,7 @@ export default {
scanFolder: "Сканувати",
toast: {
folderCreated: "Папка створена",
openInSlicerFailed: "Не вдалося відкрити у слайсері",
folderDeleted: "Папку видалено",
fileDeleted: "Файл видалено",
filesDeleted: "Видалені файли {{count}}.",
@@ -5470,6 +5471,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: "Відкрити у слайсері",
openInSlicerWith: "Відкрити у {{slicer}}",
moreSlicerOptions: "Більше варіантів слайсера",
openInSlicerFailed: "Не вдалося відкрити у слайсері",
tabs: {
model: "3D-модель",
gcode: "Попередній перегляд G-коду",
@@ -5520,6 +5524,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: "Датчики",
unavailable: "Недоступно",
blocksPrints: "{{entity}} — утримує друк, поки триває сповіщення",
states: {
open: "Відчинено",
closed: "Зачинено",
unlocked: "Розблоковано",
locked: "Заблоковано",
detected: "Виявлено",
clear: "Чисто",
wet: "Волого",
dry: "Сухо",
problem: "Проблема",
ok: "Норма",
running: "Працює",
stopped: "Зупинено",
on: "Увімк",
off: "Вимк",
},
sectionTitle: "Датчики Home Assistant",
add: "Додати датчик",
addTitle: "Додати датчик Home Assistant",
editTitle: "Редагувати датчик Home Assistant",
empty: "Датчиків ще немає. Прив'яжіть дверний контакт або термометр із Home Assistant, щоб показати його на картці принтера.",
unknownPrinter: "Невідомий принтер",
badgeBlocks: "Утримує друк",
badgeNotifies: "Сповіщає",
badgeHidden: "Приховано на картці",
printer: "Принтер",
entity: "Сутність",
searchPlaceholder: "Пошук сутностей...",
noEntities: "Немає відповідних сутностей",
name: "Відображувана назва",
alertWhen: "Сповіщати коли",
alertNever: "Ніколи — лише показувати",
alertAbove: "Вище",
alertBelow: "Нижче",
alertHint: "Стан сповіщення підсвічує датчик на картці принтера та вмикає параметри нижче.",
showOnCard: "Показувати на картці принтера",
notifyOnAlert: "Надсилати сповіщення, коли спрацьовує",
blockPrint: "Утримувати друк у черзі, поки триває сповіщення",
blockPrintHint: "Завдання лишаються в черзі й запускаються самі, коли датчик повертається до норми. Ігнорується, поки Home Assistant недоступний.",
toast: {
created: "Датчик додано",
updated: "Датчик збережено",
deleted: "Датчик видалено",
},
error: {
pickEntity: "Оберіть сутність Home Assistant",
nameRequired: "Введіть відображувану назву",
printerRequired: "Оберіть принтер",
alertRequired: "Спершу задайте умову сповіщення",
},
},
smartPlugs: {
offline: "Не в мережі",
admin: "Адміністрування",
@@ -5832,6 +5891,8 @@ export default {
notificationEvents: "Події сповіщень",
progressPercent: "(25%, 50%, 75%)",
bedCooledAfterPrint: "(після завершення друку)",
haSensorAlert: "Сповіщення датчика",
haSensorAlertDescription: "(пов'язаний датчик Home Assistant потребує уваги)",
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: "Пріоритет ntfy",
+61
View File
@@ -3808,6 +3808,7 @@ export default {
scanFolder: '扫描',
toast: {
folderCreated: '文件夹已创建',
openInSlicerFailed: '无法在切片软件中打开',
folderDeleted: '文件夹已删除',
fileDeleted: '文件已删除',
filesDeleted: '已删除 {{count}} 个文件',
@@ -5415,6 +5416,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: '在切片软件中打开',
openInSlicerWith: '用{{slicer}}打开',
moreSlicerOptions: '更多切片软件选项',
openInSlicerFailed: '无法在切片软件中打开',
tabs: {
model: '3D 模型',
gcode: 'G-code 预览',
@@ -5465,6 +5469,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: '传感器',
unavailable: '不可用',
blocksPrints: '{{entity}} — 警报期间暂停打印',
states: {
open: '打开',
closed: '关闭',
unlocked: '已解锁',
locked: '已锁定',
detected: '已检测',
clear: '无',
wet: '潮湿',
dry: '干燥',
problem: '异常',
ok: '正常',
running: '运行中',
stopped: '已停止',
on: '开',
off: '关',
},
sectionTitle: 'Home Assistant 传感器',
add: '添加传感器',
addTitle: '添加 Home Assistant 传感器',
editTitle: '编辑 Home Assistant 传感器',
empty: '还没有传感器。绑定 Home Assistant 的门磁或温度计,即可显示在打印机卡片上。',
unknownPrinter: '未知打印机',
badgeBlocks: '暂停打印',
badgeNotifies: '发送通知',
badgeHidden: '卡片上隐藏',
printer: '打印机',
entity: '实体',
searchPlaceholder: '搜索实体...',
noEntities: '没有匹配的实体',
name: '显示名称',
alertWhen: '警报条件',
alertNever: '从不 — 仅显示',
alertAbove: '高于',
alertBelow: '低于',
alertHint: '进入警报状态后会在打印机卡片上高亮显示,并启用下面的选项。',
showOnCard: '在打印机卡片上显示',
notifyOnAlert: '触发警报时发送通知',
blockPrint: '警报期间暂停队列中的打印',
blockPrintHint: '任务会留在队列中,传感器恢复后自动开始。Home Assistant 无法连接时忽略此设置。',
toast: {
created: '已添加传感器',
updated: '已保存传感器',
deleted: '已删除传感器',
},
error: {
pickEntity: '请选择一个 Home Assistant 实体',
nameRequired: '请输入显示名称',
printerRequired: '请选择一台打印机',
alertRequired: '请先设置警报条件',
},
},
smartPlugs: {
offline: '离线',
admin: '管理',
@@ -5777,6 +5836,8 @@ export default {
notificationEvents: '通知事件',
progressPercent: '(25%、50%、75%)',
bedCooledAfterPrint: '(打印完成后)',
haSensorAlert: '传感器警报',
haSensorAlertDescription: '(已绑定的 Home Assistant 传感器需要关注)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 优先级',
+61
View File
@@ -3808,6 +3808,7 @@ export default {
scanFolder: '掃描',
toast: {
folderCreated: '資料夾已建立',
openInSlicerFailed: '無法在切片軟體中開啟',
folderDeleted: '資料夾已刪除',
fileDeleted: '檔案已刪除',
filesDeleted: '已刪除 {{count}} 個檔案',
@@ -5415,6 +5416,9 @@ export default {
// Model Viewer
modelViewer: {
openInSlicer: '在切片軟體中開啟',
openInSlicerWith: '用{{slicer}}開啟',
moreSlicerOptions: '更多切片軟體選項',
openInSlicerFailed: '無法在切片軟體中開啟',
tabs: {
model: '3D 模型',
gcode: 'G-code 預覽',
@@ -5465,6 +5469,61 @@ export default {
},
// Smart Plugs
haSensors: {
label: '感測器',
unavailable: '無法使用',
blocksPrints: '{{entity}} — 警報期間暫停列印',
states: {
open: '開啟',
closed: '關閉',
unlocked: '已解鎖',
locked: '已鎖定',
detected: '已偵測',
clear: '無',
wet: '潮濕',
dry: '乾燥',
problem: '異常',
ok: '正常',
running: '運轉中',
stopped: '已停止',
on: '開',
off: '關',
},
sectionTitle: 'Home Assistant 感測器',
add: '新增感測器',
addTitle: '新增 Home Assistant 感測器',
editTitle: '編輯 Home Assistant 感測器',
empty: '還沒有感測器。綁定 Home Assistant 的門磁或溫度計,即可顯示在印表機卡片上。',
unknownPrinter: '未知印表機',
badgeBlocks: '暫停列印',
badgeNotifies: '發送通知',
badgeHidden: '卡片上隱藏',
printer: '印表機',
entity: '實體',
searchPlaceholder: '搜尋實體...',
noEntities: '沒有相符的實體',
name: '顯示名稱',
alertWhen: '警報條件',
alertNever: '永不 — 僅顯示',
alertAbove: '高於',
alertBelow: '低於',
alertHint: '進入警報狀態後會在印表機卡片上醒目顯示,並啟用下方選項。',
showOnCard: '在印表機卡片上顯示',
notifyOnAlert: '觸發警報時發送通知',
blockPrint: '警報期間暫停佇列中的列印',
blockPrintHint: '工作會留在佇列中,感測器恢復後自動開始。Home Assistant 無法連線時會忽略此設定。',
toast: {
created: '已新增感測器',
updated: '已儲存感測器',
deleted: '已移除感測器',
},
error: {
pickEntity: '請選擇一個 Home Assistant 實體',
nameRequired: '請輸入顯示名稱',
printerRequired: '請選擇一台印表機',
alertRequired: '請先設定警報條件',
},
},
smartPlugs: {
offline: '離線',
admin: '管理',
@@ -5777,6 +5836,8 @@ export default {
notificationEvents: '通知事件',
progressPercent: '(25%、50%、75%)',
bedCooledAfterPrint: '(列印完成後)',
haSensorAlert: '感測器警報',
haSensorAlertDescription: '(已綁定的 Home Assistant 感測器需要注意)',
// Per-event ntfy priority (#990)
eventPriority: {
sectionTitle: 'ntfy 優先級',
+2 -2
View File
@@ -64,7 +64,7 @@ import {
import { api } from '../api/client';
import { SliceModal } from '../components/SliceModal';
import { RunWithPipelineModal } from '../components/RunWithPipelineModal';
import { openInSlicer, type SlicerType } from '../utils/slicer';
import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDuration } from '../utils/date';
import { getCurrencySymbol } from '../utils/currency';
import { getBedTypeInfo } from '../utils/bedType';
@@ -2956,7 +2956,7 @@ export function ArchivesPage() {
// user hasn't explicitly chosen a different desktop slicer (#1329). This is
// ONLY the URI-handoff target; the in-app SliceModal still uses
// preferred_slicer for the sidecar.
const preferredSlicer: SlicerType = settings?.open_in_slicer || settings?.preferred_slicer || 'bambu_studio';
const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
const useSlicerApi = settings?.use_slicer_api ?? false;
const currency = getCurrencySymbol(settings?.currency || 'USD');
+66 -21
View File
@@ -9,6 +9,7 @@ import {
Upload,
Trash2,
Download,
ExternalLink,
MoreVertical,
ChevronRight,
FolderPlus,
@@ -73,6 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
import { useAuth } from '../contexts/AuthContext';
import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
import { formatFileSize } from '../utils/file';
import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
type SortDirection = 'asc' | 'desc';
@@ -741,14 +743,6 @@ function isSlicedFilename(filename: string): boolean {
return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
}
// Files that can be fed to the slicer sidecar (model geometry inputs).
// Excludes .gcode.* (already sliced) and any other non-model formats.
function isSliceableFilename(filename: string): boolean {
const lower = filename.toLowerCase();
if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp');
}
// File Card
interface FileCardProps {
file: LibraryFileListItem;
@@ -759,8 +753,10 @@ interface FileCardProps {
onDownload: (id: number) => void;
onPrint?: (file: LibraryFileListItem) => void;
onSlice?: (file: LibraryFileListItem) => void;
onOpenInSlicer?: (file: LibraryFileListItem) => void;
onRunPipeline?: (file: LibraryFileListItem) => void;
useSlicerApi?: boolean;
canSlice?: boolean;
onPreview3d?: (file: LibraryFileListItem) => void;
onRename?: (file: LibraryFileListItem) => void;
onGenerateThumbnail?: (file: LibraryFileListItem) => void;
@@ -773,7 +769,7 @@ interface FileCardProps {
t: TFunction;
}
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onRunPipeline, useSlicerApi, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onPrint, onSlice, onOpenInSlicer, onRunPipeline, useSlicerApi, canSlice, onPreview3d, onRename, onGenerateThumbnail, onTagClick, thumbnailVersion, hasPermission, canModify, authEnabled, showModified, t }: FileCardProps) {
const [showActions, setShowActions] = useState(false);
return (
@@ -899,16 +895,21 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
{t('common.print')}
</button>
)}
{onSlice && useSlicerApi && isSliceableFilename(file.filename) && (
{isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
<button
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
hasPermission('library:upload') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
}`}
onClick={() => { if (hasPermission('library:upload')) { onSlice(file); setShowActions(false); } }}
disabled={!hasPermission('library:upload')}
title={!hasPermission('library:upload') ? t('fileManager.noPermissionSlice') : undefined}
onClick={() => {
if (!canSlice) return;
if (useSlicerApi) onSlice?.(file);
else onOpenInSlicer?.(file);
setShowActions(false);
}}
disabled={!canSlice}
title={!canSlice ? (useSlicerApi ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload')) : undefined}
>
<Cog className="w-3.5 h-3.5" />
{useSlicerApi ? <Cog className="w-3.5 h-3.5" /> : <ExternalLink className="w-3.5 h-3.5" />}
{t('slice.action')}
</button>
)}
@@ -1145,6 +1146,45 @@ export function FileManagerPage() {
queryKey: ['settings'],
queryFn: () => api.getSettings() as Promise<AppSettings>,
});
const preferredSlicer: SlicerType = resolveDesktopSlicer(settings?.open_in_slicer, settings?.preferred_slicer);
const handleOpenInSlicer = useCallback(async (file: LibraryFileListItem) => {
try {
const { token } = await api.createLibrarySlicerToken(file.id);
const path = api.getLibrarySlicerDownloadUrl(file.id, token, file.filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
} catch {
// Fallback to direct URL (works when auth is disabled). With auth on the
// slicer may then hit a 401, so surface the failure instead of making a
// permission denial look identical to "no slicer installed".
showToast(t('fileManager.toast.openInSlicerFailed'), 'error');
const path = api.getLibraryFileDownloadUrl(file.id);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
}
}, [preferredSlicer, showToast, t]);
// Slice permission: API mode needs upload rights, the desktop handoff is a
// download. Each mirrors what the backend enforces on the endpoint that
// branch actually calls, so the UI never offers an action the server refuses.
//
// Deliberately NOT accepting the legacy `library:read` on the handoff branch.
// It looks like the safe back-compat term to include, but the slicer-token
// endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
// LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
// expands the legacy name — so a group holding only `library:read` gets a 403
// there. It cannot reach this page to find out either: GET /library/folders
// gates on the same pair. Accepting it here would only enable a menu item
// that fails, and the `library:read` -> `library:read_own` migration in
// core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
// custom role that still carries it is genuinely stuck rather than silently
// upgraded.
const canSlice = useCallback(() => {
if (settings?.use_slicer_api) {
return hasPermission('library:upload');
}
return hasAnyPermission('library:read_all', 'library:read_own');
}, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
const { data: folders, isLoading: foldersLoading } = useQuery({
queryKey: ['library-folders'],
queryFn: () => api.getLibraryFolders(),
@@ -2420,8 +2460,10 @@ export function FileManagerPage() {
onDownload={handleDownload}
onPrint={setPrintFile}
onSlice={setSliceFile}
onOpenInSlicer={handleOpenInSlicer}
onRunPipeline={setRunPipelineFile}
useSlicerApi={settings?.use_slicer_api ?? false}
canSlice={canSlice()}
onPreview3d={(f) => {
// Sliced files (.gcode / .gcode.3mf) open the same
// full-page gcode viewer the archive card uses, so
@@ -2597,18 +2639,21 @@ export function FileManagerPage() {
</button>
</>
)}
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
{isSliceableFilename(file.filename) && (
<button
onClick={() => hasPermission('library:upload') && setSliceFile(file)}
onClick={() => {
if (!canSlice()) return;
(settings?.use_slicer_api ? setSliceFile : handleOpenInSlicer)(file);
}}
className={`p-1.5 rounded transition-colors ${
hasPermission('library:upload')
canSlice()
? 'hover:bg-bambu-dark text-bambu-gray hover:text-bambu-green'
: 'text-bambu-gray/50 cursor-not-allowed'
}`}
title={hasPermission('library:upload') ? t('slice.action') : t('fileManager.noPermissionSlice')}
disabled={!hasPermission('library:upload')}
title={canSlice() ? t('slice.action') : (settings?.use_slicer_api ? t('fileManager.noPermissionSlice') : t('fileManager.noPermissionDownload'))}
disabled={!canSlice()}
>
<Cog className="w-4 h-4" />
{settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
</button>
)}
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
+2 -2
View File
@@ -11,7 +11,7 @@ import {
type MakerworldRecentImport,
type MakerworldResolvedModel,
} from '../api/client';
import { openInSlicer, type SlicerType } from '../utils/slicer';
import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
import { Button } from '../components/Button';
import { Card, CardContent, CardHeader } from '../components/Card';
import { ConfirmModal } from '../components/ConfirmModal';
@@ -185,7 +185,7 @@ export function MakerworldPage() {
// whichever slicer this button actually drives — depends on useSlicerApi.
const useSlicerApi = settingsQuery.data?.use_slicer_api ?? false;
const apiSlicer: SlicerType = settingsQuery.data?.preferred_slicer || 'bambu_studio';
const desktopSlicer: SlicerType = settingsQuery.data?.open_in_slicer || apiSlicer;
const desktopSlicer: SlicerType = resolveDesktopSlicer(settingsQuery.data?.open_in_slicer, settingsQuery.data?.preferred_slicer);
const preferredSlicer: SlicerType = useSlicerApi ? apiSlicer : desktopSlicer;
const preferredSlicerName =
preferredSlicer === 'orcaslicer' ? 'OrcaSlicer' : 'Bambu Studio';
+6
View File
@@ -110,6 +110,7 @@ import { MQTTDebugModal } from '../components/MQTTDebugModal';
import { HMSErrorModal, filterKnownHMSErrors } from '../components/HMSErrorModal';
import { AiDetectionModal } from '../components/AiDetectionModal';
import { PrinterQueueWidget } from '../components/PrinterQueueWidget';
import { PrinterHASensorRow } from '../components/PrinterHASensorRow';
import { AMSHistoryModal } from '../components/AMSHistoryModal';
import { AmsBackupModal } from '../components/AmsBackupModal';
import { HeaterHistoryModal } from '../components/HeaterHistoryModal';
@@ -6018,6 +6019,11 @@ function PrinterCard({
</div>
)}
{/* Home Assistant sensors (#1148). Outside the smartPlug block above:
a printer can have an enclosure door contact without having a plug,
and nesting it there would hide the row on exactly those setups. */}
<PrinterHASensorRow printerId={printer.id} />
{/* Connection Info & Actions */}
<div className="pt-4">
<div className="mb-3 h-[2px] bg-bambu-dark-tertiary" />
+105 -2
View File
@@ -11,7 +11,7 @@ import { fleetAudience, sponsorHref } from '../utils/fleetAudience';
import { PRESET_CATEGORIES, parsePresetTriple } from '../utils/temperatureFanPresets';
import { CALIBRATION_MODES, CALIBRATION_MODE_ACTIVE, CALIBRATION_MODE_INACTIVE } from '../utils/calibrationMode';
import { PreheatFilamentTargetsEditor } from '../components/PreheatFilamentTargetsEditor';
import type { APIKey, AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, StorageUsageResponse, CalibrationMode } from '../api/client';
import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
@@ -22,6 +22,7 @@ import { CopyButton } from '../components/CopyButton';
import { Button } from '../components/Button';
import { SmartPlugCard } from '../components/SmartPlugCard';
import { AddSmartPlugModal } from '../components/AddSmartPlugModal';
import { HASensorModal } from '../components/HASensorModal';
import { NotificationProviderCard } from '../components/NotificationProviderCard';
import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
@@ -50,7 +51,7 @@ import { availableLanguages } from '../i18n';
import { useToast } from '../contexts/ToastContext';
import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Palette } from 'lucide-react';
import { Gauge, Palette } from 'lucide-react';
import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
import type { UsersSubTab } from '../lib/settingsSearch';
@@ -184,6 +185,8 @@ export function SettingsPage() {
const [humidityDrafts, setHumidityDrafts] = useState<Record<string, string>>({});
const [showPlugModal, setShowPlugModal] = useState(false);
const [editingPlug, setEditingPlug] = useState<SmartPlug | null>(null);
const [showHASensorModal, setShowHASensorModal] = useState(false);
const [editingHASensor, setEditingHASensor] = useState<PrinterHASensor | null>(null);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<NotificationProvider | null>(null);
const [editingTemplate, setEditingTemplate] = useState<NotificationTemplate | null>(null);
@@ -434,6 +437,12 @@ export function SettingsPage() {
queryFn: api.getPrinters,
});
const { data: haSensors } = useQuery({
queryKey: ['haSensors'],
queryFn: () => api.getHASensors(),
enabled: activeTab === 'plugs',
});
// A business-sized fleet gets the commercial ask instead of the donation ask.
const sponsorAudience = fleetAudience(printers?.length ?? 0);
@@ -3523,6 +3532,88 @@ export function SettingsPage() {
</CardContent>
</Card>
)}
{/* Home Assistant sensors (#1148, #448). Sits under the plugs on the
same tab: same integration, same credentials, but read-only —
these are contacts and thermometers, not switches. */}
<div className="mt-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Gauge className="w-5 h-5 text-bambu-green" />
{t('haSensors.sectionTitle')}
</h2>
<Button
className="whitespace-nowrap"
disabled={!printers?.length}
onClick={() => {
setEditingHASensor(null);
setShowHASensorModal(true);
}}
>
<Plus className="w-4 h-4" />
{t('haSensors.add')}
</Button>
</div>
{haSensors && haSensors.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{haSensors.map((sensor) => {
const printer = printers?.find((p) => p.id === sensor.printer_id);
return (
<Card key={sensor.id}>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-white font-medium truncate">{sensor.name}</div>
<div className="text-xs text-bambu-gray truncate">{sensor.entity_id}</div>
<div className="text-xs text-bambu-gray mt-1">
{printer?.name ?? t('haSensors.unknownPrinter')}
</div>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
setEditingHASensor(sensor);
setShowHASensorModal(true);
}}
>
{t('common.edit')}
</Button>
</div>
<div className="flex flex-wrap gap-1 mt-3">
{sensor.block_print && (
<span className="px-2 py-0.5 text-xs rounded bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400">
{t('haSensors.badgeBlocks')}
</span>
)}
{sensor.notify_on_alert && (
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400">
{t('haSensors.badgeNotifies')}
</span>
)}
{!sensor.show_on_printer_card && (
<span className="px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
{t('haSensors.badgeHidden')}
</span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
) : (
<Card>
<CardContent className="py-8">
<div className="text-center text-bambu-gray">
<Gauge className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t('haSensors.empty')}</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
)}
@@ -5552,6 +5643,18 @@ export function SettingsPage() {
/>
)}
{/* Home Assistant Sensor Modal (#1148) */}
{showHASensorModal && (
<HASensorModal
sensor={editingHASensor}
printers={printers ?? []}
onClose={() => {
setShowHASensorModal(false);
setEditingHASensor(null);
}}
/>
)}
{/* Notification Modal */}
{showNotificationModal && (
<AddNotificationModal
+50
View File
@@ -28,6 +28,56 @@ export type SlicerType = 'bambu_studio' | 'orcaslicer';
type Platform = 'windows' | 'macos' | 'linux' | 'unknown';
/**
* Resolve the desktop "Open in Slicer" target. Prefers an explicit
* `open_in_slicer` override (#1329), then falls back to the API slicer's
* `preferred_slicer`, then Bambu Studio. This is ONLY the URI-handoff target;
* the in-app SliceModal keeps using `preferred_slicer` for the sidecar.
*/
export function resolveDesktopSlicer(
openInSlicer?: SlicerType | null,
preferredSlicer?: SlicerType,
): SlicerType {
return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
}
/**
* File types a slicer can be handed — both by the desktop URI handler and by
* the in-app sidecar. Source geometry only: a sliced file is an output, and
* neither slicer has anything to do with one.
*
* Lives here rather than beside either caller because both the File Manager
* (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
* decide the same thing about the same file. They used to hold separate lists,
* and the two disagreed — a card menu offered a desktop handoff for an STL
* whose own 3D preview showed "Open in Slicer" greyed out.
*/
export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
/**
* Does a `LibraryFile.file_type` name a sliceable source file?
*
* The backend stores compound extensions whole — a sliced 3MF classifies as
* `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
* membership alone is enough to exclude sliced output here.
*/
export function isSliceableFileType(fileType?: string | null): boolean {
const normalized = (fileType || '').toLowerCase();
return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
}
/**
* Does a filename name a sliceable source file?
*
* Checked against the name rather than a stored type, so the compound
* extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
*/
export function isSliceableFilename(filename: string): boolean {
const lower = filename.toLowerCase();
if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
}
/**
* Detect the user's operating system
*/