mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Compare commits
240 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28bdc5478 | ||
|
|
7f1e249849 | ||
|
|
34a72fb7c2 | ||
|
|
8d3abf414b | ||
|
|
f1130acfcc | ||
|
|
3da4eee16e | ||
|
|
3b5d219860 | ||
|
|
81ee7658f5 | ||
|
|
d3770b5463 | ||
|
|
ab4b34e203 | ||
|
|
feec283c2a | ||
|
|
4cf3235fde | ||
|
|
11184b4fd0 | ||
|
|
6c573589b5 | ||
|
|
4ff6377050 | ||
|
|
234809fad1 | ||
|
|
ab768a469f | ||
|
|
c765d2f2fb | ||
|
|
5bbb6a73cc | ||
|
|
6b2c888a8b | ||
|
|
30fcfe117b | ||
|
|
847f324cfb | ||
|
|
8e493318c0 | ||
|
|
6b601298f8 | ||
|
|
44450a3aa4 | ||
|
|
19dcc6d89b | ||
|
|
bade12ff49 | ||
|
|
4cea07a510 | ||
|
|
385bff04bc | ||
|
|
6184dbb980 | ||
|
|
180e2acbe8 | ||
|
|
35e5d0104e | ||
|
|
dc6217f4e6 | ||
|
|
a7b96ea9d6 | ||
|
|
91ac499e20 | ||
|
|
72a8dafde9 | ||
|
|
cdba011b1e | ||
|
|
f6b38f3926 | ||
|
|
b4ea8e28c5 | ||
|
|
53844b46a5 | ||
|
|
51f36a4d24 | ||
|
|
2e45f77a4b | ||
|
|
ed3173eb3f | ||
|
|
e762ed296d | ||
|
|
c9f99b35d1 | ||
|
|
8bf110ee62 | ||
|
|
2c94cc9e53 | ||
|
|
7096787be6 | ||
|
|
3466195d60 | ||
|
|
89804b810d | ||
|
|
4045ddbd1f | ||
|
|
bdcb8a3fc7 | ||
|
|
216a8a3695 | ||
|
|
8eb185b239 | ||
|
|
55f4aa61eb | ||
|
|
097e10b344 | ||
|
|
562e325972 | ||
|
|
54908115a2 | ||
|
|
9b2b433497 | ||
|
|
c79a583f7a | ||
|
|
c655413971 | ||
|
|
647cda6b9d | ||
|
|
83df15c0a3 | ||
|
|
4c733dfd45 | ||
|
|
3e326adce8 | ||
|
|
a817394a23 | ||
|
|
cf4cced388 | ||
|
|
65b4c59175 | ||
|
|
6d8434b250 | ||
|
|
fe476dd98e | ||
|
|
d14c969197 | ||
|
|
1cda64c35a | ||
|
|
30ad040ac2 | ||
|
|
469bd408a0 | ||
|
|
7e954efb10 | ||
|
|
dbd2fd19d4 | ||
|
|
2915d2221b | ||
|
|
30daed2756 | ||
|
|
588665d5f4 | ||
|
|
0034fdc425 | ||
|
|
d36632db0f | ||
|
|
9259763179 | ||
|
|
3d6bf2a303 | ||
|
|
aef4f3a3e9 | ||
|
|
8b46006644 | ||
|
|
9e783fbad6 | ||
|
|
77c9bdd694 | ||
|
|
c547505c64 | ||
|
|
1cdc0e289f | ||
|
|
43cb216ae9 | ||
|
|
18938a10ee | ||
|
|
af282b3527 | ||
|
|
84ca1e49d4 | ||
|
|
a35ba8fa5f | ||
|
|
aa07415270 | ||
|
|
b8225d9e9f | ||
|
|
a94f1ef4ff | ||
|
|
21d61c3535 | ||
|
|
455a9e4ba7 | ||
|
|
82656c8760 | ||
|
|
3c49990387 | ||
|
|
11bad70a6b | ||
|
|
4f2c073a34 | ||
|
|
4b4cb18a64 | ||
|
|
c457cf54bf | ||
|
|
6eea61dc78 | ||
|
|
ce3e59884a | ||
|
|
be19461470 | ||
|
|
f6e0d76731 | ||
|
|
c4b5d42f48 | ||
|
|
6dff1e9644 | ||
|
|
284709f850 | ||
|
|
3abab1fd45 | ||
|
|
5e2b7b53e6 | ||
|
|
11dc612bc4 | ||
|
|
6844aa292f | ||
|
|
db6cdb0745 | ||
|
|
13c37ffe51 | ||
|
|
bab1cfb906 | ||
|
|
1c2d219b89 | ||
|
|
cd7b869419 | ||
|
|
432e956eff | ||
|
|
3ae854ffcf | ||
|
|
08c9ec6749 | ||
|
|
4bd6761487 | ||
|
|
804256e3bf | ||
|
|
4888485a54 | ||
|
|
6ad5bf30f7 | ||
|
|
c3448dae91 | ||
|
|
3daae22f3d | ||
|
|
5db4c75ca0 | ||
|
|
f475f8e015 | ||
|
|
f89e2454ac | ||
|
|
24863203fb | ||
|
|
68f5651f97 | ||
|
|
c0ed97f287 | ||
|
|
9a324d98b4 | ||
|
|
ac3e3cc60f | ||
|
|
9ebfcddbdb | ||
|
|
db538e43f1 | ||
|
|
83142c726c | ||
|
|
ed29319cf9 | ||
|
|
3c679459e1 | ||
|
|
ac2f4d058e | ||
|
|
d64f5d9651 | ||
|
|
ad785a95cb | ||
|
|
beca3a8d73 | ||
|
|
88dc56d6e1 | ||
|
|
d9da60dd8d | ||
|
|
40e7b60e8c | ||
|
|
f26bcbbcce | ||
|
|
18cc906fad | ||
|
|
73afa95047 | ||
|
|
9ef06449ef | ||
|
|
9ef03067f7 | ||
|
|
b938b83136 | ||
|
|
ef9357849b | ||
|
|
68cf5a8804 | ||
|
|
24322c71cb | ||
|
|
c9aec8293f | ||
|
|
6cda236dce | ||
|
|
fe03e0ec1d | ||
|
|
c11bcecfd8 | ||
|
|
ea1869aeb3 | ||
|
|
e782fe237f | ||
|
|
d7fe72672b | ||
|
|
f2babc9027 | ||
|
|
e325948dcc | ||
|
|
f189ec826d | ||
|
|
5a67dffe4f | ||
|
|
c1a4c99059 | ||
|
|
91269f14fe | ||
|
|
fc1467ab03 | ||
|
|
3a6cf04a7e | ||
|
|
d0efb9db9e | ||
|
|
1b1f2b4ecb | ||
|
|
1116b43fbd | ||
|
|
c163b3524a | ||
|
|
f7c5efc7b2 | ||
|
|
05aebac67b | ||
|
|
8d1b2b9027 | ||
|
|
ccc90ff34c | ||
|
|
c9ee259807 | ||
|
|
e7a413e745 | ||
|
|
58602a3f1b | ||
|
|
d6ecd92480 | ||
|
|
e3cada51ac | ||
|
|
8fcee5d048 | ||
|
|
84a7b797cd | ||
|
|
03c35e06ce | ||
|
|
15ec0bf1c5 | ||
|
|
9ee162d51a | ||
|
|
b51b6ed863 | ||
|
|
c3cee54c0c | ||
|
|
30d26efb3d | ||
|
|
3582f7731d | ||
|
|
dd171252dc | ||
|
|
4059d63373 | ||
|
|
9d549050f7 | ||
|
|
d0d658f6a0 | ||
|
|
8551e32f14 | ||
|
|
4cca2b5929 | ||
|
|
d5a02390e9 | ||
|
|
077d2ab941 | ||
|
|
4e46ba071f | ||
|
|
8fd1f884dc | ||
|
|
f4f76e0121 | ||
|
|
af7874546a | ||
|
|
1fb6978ee1 | ||
|
|
eae5359fbc | ||
|
|
62ba751278 | ||
|
|
49f9d7120d | ||
|
|
d68724c689 | ||
|
|
775e9d7649 | ||
|
|
ad19f8a9e5 | ||
|
|
f957fcc717 | ||
|
|
f7e35a6d99 | ||
|
|
ef2865821c | ||
|
|
9bb5a1b999 | ||
|
|
da33ba64d1 | ||
|
|
90379f33f5 | ||
|
|
98b2900812 | ||
|
|
2b9043e2fd | ||
|
|
60bf1bbab2 | ||
|
|
20abfb3b4d | ||
|
|
d90b91604f | ||
|
|
0bc98beac5 | ||
|
|
561e94b755 | ||
|
|
7c83316797 | ||
|
|
8646c40957 | ||
|
|
1bdd7d224a | ||
|
|
13183e365a | ||
|
|
aa443c6e83 | ||
|
|
1234f0830a | ||
|
|
db6d306b35 | ||
|
|
6530a6af06 | ||
|
|
800c45536e | ||
|
|
0f203ce7ca | ||
|
|
30e4577838 | ||
|
|
559571a35f |
261 changed files with 38368 additions and 1957 deletions
61
.env.example
61
.env.example
|
|
@ -66,3 +66,64 @@ LOG_TO_FILE=true
|
|||
# LDAP is governed by its own ldap_enabled toggle and is not affected.
|
||||
# Leave unset for normal operation.
|
||||
# BAMBUDDY_LOCAL_LOGIN=true
|
||||
|
||||
# --- OIDC provider from the environment (#2593) ------------------------------
|
||||
# Defines ONE OIDC provider declaratively, for deployments that are managed by
|
||||
# compose files or GitOps and never touch the settings UI. Providers created in
|
||||
# the UI are unaffected and keep working alongside this one.
|
||||
#
|
||||
# Activates only when all four required vars below are set; an empty value
|
||||
# counts as unset. The provider is written on startup and re-applied on every
|
||||
# boot, so the UI shows it as read-only and the API refuses to change it -- an
|
||||
# edit there would be reverted at the next restart anyway.
|
||||
#
|
||||
# Removing the vars DISABLES the provider rather than deleting it: accounts
|
||||
# linked to it would otherwise lose their link permanently. Re-adding the vars
|
||||
# enables it again with those links intact.
|
||||
#
|
||||
# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
|
||||
#
|
||||
# Required:
|
||||
# BAMBUDDY_OIDC_NAME=Keycloak
|
||||
# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
|
||||
# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
|
||||
# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
|
||||
#
|
||||
# Optional, shown with their defaults:
|
||||
# BAMBUDDY_OIDC_SCOPES=openid email profile
|
||||
# BAMBUDDY_OIDC_ENABLED=true
|
||||
# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
|
||||
# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
|
||||
# BAMBUDDY_OIDC_EMAIL_CLAIM=email
|
||||
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
|
||||
# BAMBUDDY_OIDC_ICON_URL=
|
||||
# BAMBUDDY_OIDC_AUTOLOGIN=false
|
||||
# BAMBUDDY_OIDC_DEFAULT_GROUP=
|
||||
#
|
||||
# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
|
||||
# uses the default; any other value is rejected and the provider is skipped.
|
||||
#
|
||||
# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
|
||||
# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
|
||||
# -- group ids are assigned per install, so the same compose file would point at
|
||||
# a different group on every deployment. A name that matches no group is
|
||||
# refused: the provider is left as it was and the reason is logged, rather than
|
||||
# quietly creating under-privileged users the locked UI could not correct. On a
|
||||
# FIRST boot that means no provider is created at all and no SSO button appears
|
||||
# -- create the group first. Removing the variable clears the group again.
|
||||
#
|
||||
# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
|
||||
# the same email address. With EMAIL_CLAIM=email it is refused unless
|
||||
# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
|
||||
# verify addresses would let anyone claim someone else's account. The whole
|
||||
# config is then skipped and logged; the app still starts.
|
||||
#
|
||||
# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
|
||||
# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
|
||||
# URL like http://keycloak:8080 is refused with a single log line and no SSO
|
||||
# button; use the externally-reachable HTTPS issuer URL instead.
|
||||
#
|
||||
# NAME is matched against the existing providers on every boot: setting it to
|
||||
# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
|
||||
# issuer, client id and secret are replaced and it becomes read-only). Pick a
|
||||
# name that doesn't collide unless that takeover is intended.
|
||||
|
|
|
|||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -201,15 +201,47 @@ jobs:
|
|||
if path and not info.get('dev') and not info.get('devOptional'):
|
||||
prod.add(path.split('node_modules/')[-1])
|
||||
vulns = data.get('vulnerabilities', {})
|
||||
# Documented advisory exceptions: high/critical findings whose only offered
|
||||
# 'fix' is a semver-major change and which do not apply to how Bambuddy ships.
|
||||
# Keyed by GHSA id. An entry only holds while the fix stays major-only (see
|
||||
# fix_is_major below) - once upstream backports, the gate fails until we take
|
||||
# the patch. That is what retired the one entry this list used to carry:
|
||||
# GHSA-qwww-vcr4-c8h2 (React Router RSC-mode CSRF) shipped in 7.18.2, so the
|
||||
# pin moved rather than the exception staying.
|
||||
ALLOWLIST = set()
|
||||
def advisory_ids(name, seen=None):
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen:
|
||||
return set()
|
||||
seen.add(name)
|
||||
ids = set()
|
||||
for item in vulns.get(name, {}).get('via', []):
|
||||
if isinstance(item, dict):
|
||||
url = item.get('url', '')
|
||||
if '/advisories/' in url:
|
||||
ids.add(url.rsplit('/', 1)[-1])
|
||||
elif isinstance(item, str):
|
||||
ids |= advisory_ids(item, seen)
|
||||
return ids
|
||||
def fix_is_major(v):
|
||||
fa = v.get('fixAvailable')
|
||||
return isinstance(fa, dict) and fa.get('isSemVerMajor')
|
||||
def exempt(name, v):
|
||||
ids = advisory_ids(name)
|
||||
return bool(ids) and ids <= ALLOWLIST and fix_is_major(v)
|
||||
fixable = {n: v for n, v in vulns.items()
|
||||
if n in prod and v.get('severity') in ('high', 'critical') and v.get('fixAvailable')}
|
||||
if n in prod and v.get('severity') in ('high', 'critical')
|
||||
and v.get('fixAvailable') and not exempt(n, v)}
|
||||
skipped = len(vulns) - len({n: v for n, v in vulns.items() if n in prod})
|
||||
if fixable:
|
||||
for name, v in fixable.items():
|
||||
print(f'FIXABLE {v[\"severity\"].upper()}: {name}')
|
||||
sys.exit(1)
|
||||
total = sum(1 for n, v in vulns.items() if n in prod and v.get('severity') in ('high', 'critical'))
|
||||
exempted = sorted(n for n, v in vulns.items() if n in prod and exempt(n, v))
|
||||
print(f'npm audit: {total} high/critical (0 fixable), {len(vulns)} total ({skipped} npm-internal filtered)')
|
||||
if exempted:
|
||||
print('exempted (documented, unreachable): ' + ', '.join(exempted))
|
||||
"
|
||||
|
||||
frontend-typecheck:
|
||||
|
|
|
|||
37
.github/workflows/security.yml
vendored
37
.github/workflows/security.yml
vendored
|
|
@ -308,13 +308,46 @@ jobs:
|
|||
}
|
||||
}
|
||||
const vulns = results.vulnerabilities || {};
|
||||
// Documented advisory exceptions (keyed by GHSA id) - see ci.yml for the
|
||||
// full rationale and the matching hard gate. GHSA-qwww-vcr4-c8h2: React
|
||||
// Router RSC-mode CSRF, not reachable from Bambuddy's BrowserRouter SPA
|
||||
// (@react-router/server not installed); react-router/-dom pinned to 7.18.1
|
||||
// (the most-patched 7.x), no non-major fix exists. Auto-surfaces again if a
|
||||
// non-major fix ships.
|
||||
const ALLOWLIST = new Set(['GHSA-qwww-vcr4-c8h2']);
|
||||
function advisoryIds(name, seen) {
|
||||
seen = seen || new Set();
|
||||
if (seen.has(name)) return new Set();
|
||||
seen.add(name);
|
||||
const ids = new Set();
|
||||
for (const item of (vulns[name] || {}).via || []) {
|
||||
if (item && typeof item === 'object') {
|
||||
const url = item.url || '';
|
||||
if (url.includes('/advisories/')) ids.add(url.split('/').pop());
|
||||
} else if (typeof item === 'string') {
|
||||
for (const id of advisoryIds(item, seen)) ids.add(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
function fixIsMajor(info) {
|
||||
const fa = info.fixAvailable;
|
||||
return fa && typeof fa === 'object' && fa.isSemVerMajor;
|
||||
}
|
||||
function exempt(name, info) {
|
||||
const ids = advisoryIds(name);
|
||||
return ids.size > 0 && [...ids].every(id => ALLOWLIST.has(id)) && fixIsMajor(info);
|
||||
}
|
||||
const filtered = {};
|
||||
const flagged = {};
|
||||
for (const [name, info] of Object.entries(vulns)) {
|
||||
if (prodDeps.has(name)) filtered[name] = info;
|
||||
if (!prodDeps.has(name)) continue;
|
||||
filtered[name] = info;
|
||||
if (!exempt(name, info)) flagged[name] = info;
|
||||
}
|
||||
results.vulnerabilities = filtered;
|
||||
fs.writeFileSync('npm-audit-results.json', JSON.stringify(results, null, 2));
|
||||
const count = Object.keys(filtered).length;
|
||||
const count = Object.keys(flagged).length;
|
||||
console.log(count > 0
|
||||
? count + ' production vulnerabilities found'
|
||||
: 'No production vulnerabilities (filtered ' + Object.keys(vulns).length + ' npm-internal entries)');
|
||||
|
|
|
|||
95
.github/workflows/windows-installer.yml
vendored
95
.github/workflows/windows-installer.yml
vendored
|
|
@ -6,29 +6,47 @@ name: Windows Installer
|
|||
# - Tag push matching v* (release builds, uploaded as a release asset)
|
||||
# - Manual dispatch (for testing the build pipeline)
|
||||
#
|
||||
# The installer is unsigned until SignPath OSS approval lands. Once it
|
||||
# does, add the SignPath GitHub Action between the ISCC step and the
|
||||
# upload step.
|
||||
# Release tags are Authenticode-signed through the SignPath Foundation OSS
|
||||
# program. Daily prereleases are deliberately left unsigned so they don't burn
|
||||
# the OSS signing quota; use the `sign` dispatch input to exercise the signing
|
||||
# path by hand.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sign:
|
||||
description: 'Submit the installer to SignPath for signing'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Least-privilege per CodeQL actions/missing-workflow-permissions.
|
||||
# contents: write is required by softprops/action-gh-release to attach
|
||||
# the .exe to a tag release; the manual-dispatch path doesn't trigger
|
||||
# that step and could run with read-only, but a single workflow-level
|
||||
# block keeps the surface auditable in one place.
|
||||
# actions: read lets the SignPath connector download the uploaded artifact
|
||||
# through the API. Declaring a permissions block at all drops every scope we
|
||||
# don't name to `none`, so the signing step fails to fetch the artifact
|
||||
# without it.
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
env:
|
||||
# Sign real release tags but not `-daily.` prereleases, and let a manual
|
||||
# run opt in. GitHub's `||` returns the *last* operand when everything is
|
||||
# falsy (an empty string here, not `false`), so every use site compares
|
||||
# against the string 'true' rather than treating this as a boolean.
|
||||
SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -57,6 +75,73 @@ jobs:
|
|||
& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" bambuddy.iss
|
||||
shell: pwsh
|
||||
|
||||
# SignPath signs a *GitHub artifact*, not a workspace path: the connector
|
||||
# pulls the artifact back out through the API, which is why this upload
|
||||
# has to happen before signing and why upload-artifact must be v4 or newer
|
||||
# (older versions expose no `artifact-id` output). Kept as a separate,
|
||||
# clearly-named artifact so an unsigned build is never mistaken for a
|
||||
# signed one when downloading from the run page.
|
||||
- name: Upload unsigned installer
|
||||
id: upload_unsigned
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bambuddy-windows-installer-unsigned
|
||||
path: installers/windows/build/output/*.exe
|
||||
if-no-files-found: error
|
||||
|
||||
# The artifact arrives at SignPath as a .zip (that is simply what
|
||||
# upload-artifact produces), so the artifact configuration on the SignPath
|
||||
# side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
|
||||
# left at its default the signed archive is extracted again here, so
|
||||
# `signed/` ends up holding the bare .exe.
|
||||
- name: Sign installer (SignPath)
|
||||
if: env.SIGN == 'true'
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
# Not a credential -- the organization ID appears in ordinary SignPath
|
||||
# URLs and is useless without the API token above.
|
||||
organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
|
||||
project-slug: 'bambuddy'
|
||||
signing-policy-slug: 'test-signing'
|
||||
github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: installers/windows/build/signed
|
||||
|
||||
# Replace the unsigned binary in-place so every downstream step (alias,
|
||||
# artifact upload, release attachment) keeps working off one directory and
|
||||
# cannot accidentally publish the unsigned copy.
|
||||
- name: Promote signed installer
|
||||
if: env.SIGN == 'true'
|
||||
shell: pwsh
|
||||
working-directory: installers/windows/build
|
||||
run: |
|
||||
$signed = @(Get-ChildItem -Path signed -Filter *.exe)
|
||||
if ($signed.Count -ne 1) {
|
||||
throw "expected exactly one signed .exe, found $($signed.Count)"
|
||||
}
|
||||
Move-Item -Force $signed[0].FullName (Join-Path output $signed[0].Name)
|
||||
Write-Host "promoted signed installer: $($signed[0].Name)"
|
||||
|
||||
# Fail loudly rather than shipping an unsigned .exe under a signed
|
||||
# release. The test certificate is self-signed, so Windows reports the
|
||||
# signature as untrusted (`UnknownError`) -- that is expected and is not
|
||||
# what this checks. Only the absence of a signature is treated as a
|
||||
# failure; swap in a stricter assertion once the production certificate
|
||||
# is imported.
|
||||
- name: Verify signature
|
||||
if: env.SIGN == 'true'
|
||||
shell: pwsh
|
||||
working-directory: installers/windows/build/output
|
||||
run: |
|
||||
Get-ChildItem -Filter *.exe | ForEach-Object {
|
||||
$sig = Get-AuthenticodeSignature $_.FullName
|
||||
if ($sig.Status -eq 'NotSigned') {
|
||||
throw "$($_.Name) carries no Authenticode signature"
|
||||
}
|
||||
Write-Host "$($_.Name): $($sig.Status) / $($sig.SignerCertificate.Subject)"
|
||||
}
|
||||
|
||||
# Stable + beta tag releases (e.g. v0.2.5b1, v0.3.0) get an unversioned
|
||||
# copy alongside the versioned filename so external surfaces (website,
|
||||
# wiki, newsletters) can link to a stable URL that survives version
|
||||
|
|
@ -70,6 +155,8 @@ jobs:
|
|||
# semantically confusing next to the date-stamped versioned name on a
|
||||
# daily prerelease page, and (b) there's no stable "latest daily" URL
|
||||
# anyway (`latest` skips prereleases), so the alias adds no value there.
|
||||
#
|
||||
# Runs after signing so the alias is a copy of the *signed* binary.
|
||||
- name: Create unversioned alias (non-daily tags only)
|
||||
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
|
||||
shell: pwsh
|
||||
|
|
@ -81,7 +168,7 @@ jobs:
|
|||
Write-Host "alias: bambuddy-windows-x64-setup.exe -> $($versioned.Name)"
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: bambuddy-windows-installer
|
||||
path: installers/windows/build/output/*.exe
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
|||
- [@MethodicalMartian](https://github.com/MethodicalMartian)
|
||||
- [@brianharwell](https://github.com/brianharwell)
|
||||
- [@shosier01](https://github.com/shosier01)
|
||||
- [@freifunk-bamberg](https://github.com/freifunk-bamberg)
|
||||
|
||||
## Backers ($5/mo+)
|
||||
|
||||
|
|
@ -67,6 +68,9 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
|||
- [@kycrna](https://github.com/kycrna)
|
||||
- [@iljur](https://github.com/iljur)
|
||||
- [@bhamiltoncx](https://github.com/bhamiltoncx)
|
||||
- [@g7ufo](https://github.com/g7ufo)
|
||||
- [@Heidelberger2000](https://github.com/Heidelberger2000)
|
||||
- [@MorganMLGman](https://github.com/MorganMLGman)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
118
CHANGELOG.md
118
CHANGELOG.md
File diff suppressed because one or more lines are too long
|
|
@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
|
|||
|
||||
### Locale Files
|
||||
|
||||
Translations live in `frontend/src/i18n/locales/`:
|
||||
Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
|
||||
|
||||
| File | Language |
|
||||
|------|----------|
|
||||
| `en.ts` | English (primary) |
|
||||
| `de.ts` | German |
|
||||
| `fr.ts` | French |
|
||||
| `ja.ts` | Japanese |
|
||||
| `pt-BR.ts` | Brazilian Portuguese |
|
||||
[...]
|
||||
check for possibly more files!!!
|
||||
To see the current set of locales and check your work:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run check:i18n
|
||||
```
|
||||
|
||||
### Adding New Strings
|
||||
|
||||
1. Add the key to the appropriate section in **all three** locale files
|
||||
1. Add the key to the appropriate section in **every** locale file
|
||||
2. Use the `useTranslation` hook in your component:
|
||||
|
||||
```tsx
|
||||
|
|
@ -253,9 +250,9 @@ function MyComponent() {
|
|||
|
||||
### Important Notes
|
||||
|
||||
- All three locale files must use the **same key structure** — same nesting, same key paths
|
||||
- Always add keys to all three locales to maintain parity
|
||||
- Run frontend tests after changes — locale parity is validated
|
||||
- Every locale file must use the **same key structure** — same nesting, same key paths
|
||||
- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
|
||||
- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
|
||||
- If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
|
||||
|
||||
## Authentication & Permissions
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
<p align="center">
|
||||
<strong>Your printers. No cloud. Your rules.</strong><br>
|
||||
Self-hosted command center for Bambu Lab — from one A1 to a 40-printer farm.
|
||||
Self-hosted command center for Bambu Lab — from one A1 to an entire print farm.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -57,6 +57,7 @@
|
|||
<a href="https://hackaday.com/2026/06/13/bambuddy-says-bye-to-bambu-lab-cloud-services/"><img src="https://img.shields.io/badge/Hackaday-Read-F2A724?style=flat-square&labelColor=000000" alt="Hackaday"></a>
|
||||
<a href="https://www.xda-developers.com/finally-have-full-control-bambu-lab-printer-ditched-bambu-cloud/"><img src="https://img.shields.io/badge/XDA--Developers-Read-C8102E?style=flat-square" alt="XDA-Developers"></a>
|
||||
<a href="https://www.howtogeek.com/free-your-bambu-lab-3d-printer-from-the-cloud/"><img src="https://img.shields.io/badge/How--To%20Geek-Read-33A6CA?style=flat-square" alt="How-To Geek"></a>
|
||||
<a href="https://www.makeuseof.com/free-browser-tool-beats-bambu-lab-at-own-game/"><img src="https://img.shields.io/badge/MakeUseOf-Read-E02D2D?style=flat-square" alt="MakeUseOf"></a>
|
||||
<a href="https://www.fabbaloo.com/news/bambuddy-launches-as-open-source-alternative-to-bambu-labs-cloud"><img src="https://img.shields.io/badge/Fabbaloo-Read-F77B0F?style=flat-square" alt="Fabbaloo"></a>
|
||||
<a href="https://itsfoss.com/news/bambuddy-self-hosted-bambu-lab-alternative/"><img src="https://img.shields.io/badge/It's%20FOSS-Read-00B5AD?style=flat-square" alt="It's FOSS"></a>
|
||||
<a href="https://www.igorslab.de/en/bambuddy-the-silent-alternative-to-the-bamboo-cloud/"><img src="https://img.shields.io/badge/Igor's%20Lab-Read-E10000?style=flat-square" alt="Igor's Lab"></a>
|
||||
|
|
@ -155,6 +156,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
|
||||
### 📊 Monitoring & Control
|
||||
- Real-time printer status via WebSocket
|
||||
- **Print progress in the browser tab** — optional (off by default, toggle under Settings → Appearance): shows the soonest-finishing print's percentage in the tab title and a progress-ring favicon in your theme accent colour
|
||||
- Live camera streaming (MJPEG) & snapshots with multi-viewer support — most Bambu printers only allow one upstream connection, so Bambuddy fans out a single shared stream to all browser tabs / cards / overlays
|
||||
- **Cam Wall view** — Toggle the Printers page from cards into a responsive grid of camera tiles for at-a-glance monitoring across the whole farm. On-screen tiles stream live up to a configurable cap (default 4) so RPi installs stay sustainable; the rest fall back to periodic snapshot polling, and off-screen tiles pause entirely. Per-user settings (live cap, snapshot interval); click any tile to open the floating viewer or the dedicated camera window depending on your existing camera-view preference
|
||||
- **Long-lived camera tokens** for Home Assistant / Frigate / kiosks — mint a token from Settings → API Keys, paste it once, capped at 365 days, revocable at any time (no infinite tokens — leaked permanent tokens are unsafe by design)
|
||||
|
|
@ -182,7 +184,7 @@ Optional but recommended — drop the [`slicer-api/` Compose stack](slicer-api/R
|
|||
- Configurable drying presets per filament type (temperature & duration for AMS 2 Pro and AMS-HT)
|
||||
- **Per-filament humidity threshold** — Set a different humidity trigger per filament type (e.g. Nylon at 20%, PLA at 60%, ASA at 30%) instead of one global value. Mixed-material AMS units use the most-restrictive threshold across the loaded spools so a single PLA + Nylon unit triggers at Nylon's level. Drives both the auto-drying scheduler and the hourly humidity alarm so the two can never disagree on whether a unit is "too humid"
|
||||
- Dual external spool support for H2D (Ext-L / Ext-R)
|
||||
- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 11 supported locales
|
||||
- **HMS error monitoring with one-click actions** — Live HMS error log with history and the same Resume / Stop / Continue / Retry / Check Assistant / Don't Remind Me action buttons BambuStudio shows. Click and the matching MQTT command goes back to the printer — no more walking to the device just to dismiss a paused-print dialog. Catalog covers every Bambu model (X1 / P1 / A1 / H2 series); buttons are translated in all 13 supported locales
|
||||
- **Heater history charts** — Bambuddy logs nozzle, bed, and chamber readings every minute and surfaces them via a tiny chart icon on each heater tile in the printer card. Click for a per-heater modal with current / average / min / max stats, target overlay, and a 6h / 24h / 48h / 7d time range — works on read-only chamber sensors (X1C / P2S) too. AMS humidity and temperature get the same treatment (already shipped).
|
||||
- Print success rates & trends
|
||||
- Filament usage tracking
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
"""Pure helper functions for OIDC routes.
|
||||
|
||||
Hosts the SSRF guard for admin-supplied icon URLs. Stricter than
|
||||
``_spoolman_helpers.assert_safe_spoolman_url`` — Spoolman intentionally allows
|
||||
loopback/RFC-1918 (same-LAN topology) while OIDC icons must be reachable on
|
||||
the public internet (IdP-hosted), so private addresses there are SSRF probes.
|
||||
Hosts the public-internet SSRF guard, used for both admin-supplied icon URLs
|
||||
and OIDC issuer URLs (via ``schemas.auth._validate_issuer_url``). Stricter
|
||||
than ``_url_safety.assert_safe_lan_service_url`` — LAN services intentionally
|
||||
allow loopback/RFC-1918 (same-host/same-LAN topology) while an IdP must be
|
||||
reachable on the public internet, so a private address there is an SSRF probe
|
||||
rather than a configuration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -11,15 +13,21 @@ from __future__ import annotations
|
|||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
|
||||
from backend.app.api.routes._url_safety import (
|
||||
CLOUD_METADATA_HOSTNAMES,
|
||||
CLOUD_METADATA_IPS,
|
||||
NUMERIC_IP_RE,
|
||||
unwrap_ipv4_mapped,
|
||||
)
|
||||
|
||||
|
||||
def assert_safe_public_https_url(url: str) -> None:
|
||||
"""Raise ValueError if *url* is unsafe to fetch as a public HTTPS resource.
|
||||
|
||||
Used for OIDC provider icon URLs (#1333). Stricter than the Spoolman SSRF
|
||||
guard: also rejects loopback, private (RFC-1918), and link-local addresses
|
||||
because an OIDC icon legitimately lives only on the public internet.
|
||||
Used for OIDC provider icon URLs (#1333) and OIDC issuer URLs. Stricter
|
||||
than the LAN-service SSRF guard: also rejects loopback, private
|
||||
(RFC-1918), and link-local addresses because an IdP and its icon
|
||||
legitimately live only on the public internet.
|
||||
|
||||
Checks performed:
|
||||
- Scheme must be ``https`` (no ``http://``, ``file://``, ``gopher://``, …).
|
||||
|
|
@ -35,9 +43,12 @@ def assert_safe_public_https_url(url: str) -> None:
|
|||
- IPv4-mapped IPv6 (``::ffff:127.0.0.1``) — unwrapped before the IP-class
|
||||
check so an attacker can't bypass via IPv6 encoding.
|
||||
|
||||
Hostname-based addresses are accepted without DNS resolution (consistent
|
||||
with ``_validate_issuer_url`` policy — the operator is trusted to
|
||||
configure a sensible IdP host).
|
||||
Hostname-based addresses are otherwise accepted without DNS resolution —
|
||||
the operator is trusted to configure a sensible IdP host, and resolving
|
||||
here would both add a TOCTOU gap (DNS can change between validation and
|
||||
request) and make the validator issue network requests of its own. The
|
||||
fixed cloud-metadata hostnames are the exception: matching them is a
|
||||
literal string comparison, not a resolution.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() != "https":
|
||||
|
|
@ -45,6 +56,14 @@ def assert_safe_public_https_url(url: str) -> None:
|
|||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# "https:///path" parses to an empty hostname; without this it reaches the
|
||||
# ip_address() ValueError branch and is accepted as a symbolic hostname.
|
||||
if not hostname:
|
||||
raise ValueError("icon URL must include a hostname")
|
||||
|
||||
if hostname in CLOUD_METADATA_HOSTNAMES:
|
||||
raise ValueError("icon URL must not point to a cloud metadata endpoint")
|
||||
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError("icon URL must not use numeric-encoded IP addresses")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,17 +5,15 @@ No heavy dependencies — importable in unit tests without the full backend stac
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, NUMERIC_IP_RE, unwrap_ipv4_mapped
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -80,61 +78,17 @@ class NormalizedFilament(TypedDict):
|
|||
|
||||
|
||||
def assert_safe_spoolman_url(url: str) -> None:
|
||||
"""Raise ValueError if *url* should be blocked as an SSRF risk.
|
||||
"""Raise ValueError if the Spoolman *url* should be blocked as an SSRF risk.
|
||||
|
||||
Bambuddy is typically deployed on a home LAN alongside Spoolman, so
|
||||
loopback (127.0.0.1) and RFC-1918 private ranges (192.168.x.x, 10.x.x.x,
|
||||
172.16-31.x) must be permitted — they are THE normal Spoolman topology.
|
||||
This guard therefore targets the genuinely dangerous cases only.
|
||||
Thin wrapper over the shared LAN-service policy — see
|
||||
``_url_safety.assert_safe_lan_service_url`` for what is and isn't
|
||||
rejected, and why loopback/RFC-1918 are deliberately permitted (running
|
||||
Spoolman on the same host or home LAN is THE normal topology).
|
||||
|
||||
Checks performed:
|
||||
- Scheme must be http or https (no file://, gopher://, dict://, etc.).
|
||||
- Numeric-encoded IP addresses in decimal (e.g. ``2130706433``) or hex
|
||||
(e.g. ``0x7f000001``) are rejected. Python's ``ipaddress`` module raises
|
||||
``ValueError`` for these forms so they would otherwise bypass the
|
||||
explicit-IP block below, but libc (and browsers) resolve them as valid
|
||||
IPv4 addresses.
|
||||
- Cloud provider metadata endpoints (169.254.169.254, 100.100.100.200,
|
||||
fd00:ec2::254) are blocked — the classic SSRF credential-exfil target.
|
||||
- Multicast (224.0.0.0/4, ff00::/8) and unspecified (0.0.0.0, ::) addresses
|
||||
are blocked — pointless as a destination and suggests misuse.
|
||||
- IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are unwrapped so they cannot
|
||||
bypass the checks above.
|
||||
|
||||
Hostname-based addresses ("localhost", "spoolman.lan", "internal.corp")
|
||||
are out of scope — DNS resolution is deliberately not performed here.
|
||||
Kept as a named function because the "Spoolman URL …" wording in its
|
||||
errors is user-facing and asserted by existing tests.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
raise ValueError("Spoolman URL must use http or https")
|
||||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# Reject decimal- and hex-encoded IPs (e.g. http://2130706433/ or
|
||||
# http://0x7f000001/). These slip past ipaddress.ip_address() but libc
|
||||
# (and browsers) parse them as IPv4 — an obvious bypass if not caught.
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError("Spoolman URL must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
# Not a bare IP address — includes intentional cases such as "localhost" and
|
||||
# RFC-1918 hostnames ("spoolman.lan", "192.168.1.10" would be caught above as
|
||||
# a dotted-decimal IP; symbolic names resolve via DNS which is out of scope).
|
||||
# Running Spoolman on the same host or home LAN is the standard Bambuddy
|
||||
# topology, so loopback and private ranges are deliberately NOT blocked here.
|
||||
return
|
||||
|
||||
# Unwrap IPv4-mapped IPv6 (::ffff:169.254.169.254 etc.) so attackers can't
|
||||
# encode a blocked IPv4 into an IPv6 literal to bypass the check.
|
||||
effective = unwrap_ipv4_mapped(addr)
|
||||
|
||||
if effective in CLOUD_METADATA_IPS:
|
||||
raise ValueError("Spoolman URL must not point to a cloud metadata endpoint")
|
||||
|
||||
if effective.is_multicast or effective.is_unspecified:
|
||||
raise ValueError("Spoolman URL must not point to a multicast or unspecified address")
|
||||
assert_safe_lan_service_url(url, label="Spoolman URL")
|
||||
|
||||
|
||||
_COLOR_HEX_RE = re.compile(r"^[0-9A-Fa-f]{6}$")
|
||||
|
|
|
|||
|
|
@ -1,19 +1,31 @@
|
|||
"""Shared URL-safety primitives used by both SSRF guards in this package.
|
||||
"""Shared URL-safety primitives for the SSRF guards in this package.
|
||||
|
||||
The two top-level assertion functions —
|
||||
``_spoolman_helpers.assert_safe_spoolman_url`` (Spoolman, deliberately allows
|
||||
loopback/RFC-1918 because same-LAN deployment is the standard topology) and
|
||||
``_oidc_helpers.assert_safe_public_https_url`` (OIDC icons, must be reachable
|
||||
on the public internet, so loopback/private are rejected) — share the
|
||||
*data* (cloud-metadata IP set, numeric-encoded-IP regex) but not the
|
||||
*policy*. Only the data lives here. The functions stay in their respective
|
||||
modules with their distinct policies intact.
|
||||
Bambuddy has exactly two outbound-URL policies, and which one applies is a
|
||||
property of the *service*, not of the caller:
|
||||
|
||||
- **LAN-service** (``assert_safe_lan_service_url`` below) — the service
|
||||
legitimately lives on the same host or home LAN, so loopback and RFC-1918
|
||||
must be permitted; blocking them would break the normal topology. Used for
|
||||
Spoolman, self-hosted notification servers (ntfy, Bark, Gotify, custom
|
||||
webhooks), Home Assistant, the Obico ML endpoint and the slicer sidecars.
|
||||
- **Public-internet** (``_oidc_helpers.assert_safe_public_https_url``) — the
|
||||
resource can only sensibly live on the public internet, so a private
|
||||
address is an SSRF probe rather than a configuration. Used for OIDC issuer
|
||||
and icon URLs.
|
||||
|
||||
Both reject the cases that are dangerous regardless of topology: non-HTTP
|
||||
schemes, numeric-encoded IPs, cloud-metadata endpoints, multicast and
|
||||
unspecified addresses, and IPv4-mapped IPv6 encodings of any of the above.
|
||||
|
||||
The LAN-service policy lives here because it now has several callers; the
|
||||
public-internet policy stays in ``_oidc_helpers`` next to its only consumer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Cloud-provider metadata endpoints — the classic SSRF credential-exfil
|
||||
# targets. Both guards reject these unconditionally.
|
||||
|
|
@ -28,6 +40,18 @@ CLOUD_METADATA_IPS = frozenset(
|
|||
}
|
||||
)
|
||||
|
||||
# The DNS-name form of the same targets. Neither guard resolves hostnames (see
|
||||
# the TOCTOU note on each), so an IP blocklist alone cannot catch these — but a
|
||||
# literal-string match needs no resolution and costs nothing. These names only
|
||||
# resolve inside the respective cloud, so there is no legitimate reason for any
|
||||
# Bambuddy integration to point at one.
|
||||
CLOUD_METADATA_HOSTNAMES = frozenset(
|
||||
{
|
||||
"metadata.google.internal", # GCP
|
||||
"metadata.goog", # GCP short form
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# libc and browsers parse numeric-encoded IP forms (decimal ``2130706433``
|
||||
# for 127.0.0.1, hex ``0x7f000001``) but Python's ``ipaddress.ip_address``
|
||||
|
|
@ -49,3 +73,68 @@ def unwrap_ipv4_mapped(
|
|||
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped
|
||||
return addr
|
||||
|
||||
|
||||
def assert_safe_lan_service_url(url: str, *, label: str) -> None:
|
||||
"""Raise ValueError if *url* is unsafe for a service that may live on the LAN.
|
||||
|
||||
``label`` names the setting in the error message ("Spoolman URL", "ntfy
|
||||
server URL", …) so the user sees which field they need to correct.
|
||||
|
||||
Loopback (127.0.0.1) and RFC-1918 private ranges are deliberately
|
||||
**permitted** — Bambuddy is self-hosted and running Spoolman, ntfy,
|
||||
Bark, Home Assistant, an Obico ML endpoint or a slicer sidecar on the
|
||||
same host or home LAN is THE normal topology, not an attack. A blanket
|
||||
private-address block would break those integrations for most installs.
|
||||
|
||||
What is rejected is dangerous under any topology:
|
||||
|
||||
- Schemes other than http/https. ``httpx`` already raises
|
||||
``UnsupportedProtocol`` for ``file://``/``gopher://`` etc., so this is
|
||||
about returning a clear validation error at configuration time rather
|
||||
than an opaque failure at delivery time.
|
||||
- Numeric-encoded IPv4 (decimal ``2130706433``, hex ``0x7f000001``) —
|
||||
libc and browsers resolve these, but Python's ``ipaddress`` raises
|
||||
ValueError on them, so they would slip past the checks below.
|
||||
- Cloud-provider metadata endpoints — the high-value SSRF target, and
|
||||
never a legitimate destination for any of these services.
|
||||
- Multicast and unspecified addresses — pointless as a destination and
|
||||
indicative of misuse.
|
||||
- IPv4-mapped IPv6 encodings of any of the above.
|
||||
|
||||
Symbolic hostnames are otherwise accepted without DNS resolution, matching
|
||||
the public-internet guard: resolution here would be both a TOCTOU (DNS can
|
||||
change between validation and request) and a request the validator
|
||||
shouldn't be making. The one exception is the fixed set of cloud-metadata
|
||||
hostnames, which is a literal-string match and needs no resolution.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme.lower() not in ("http", "https"):
|
||||
raise ValueError(f"{label} must use http or https")
|
||||
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
|
||||
# "http:///path" parses to an empty hostname. Never a valid destination,
|
||||
# and without this it falls through the ip_address() ValueError branch
|
||||
# below and is accepted as if it were a symbolic hostname.
|
||||
if not hostname:
|
||||
raise ValueError(f"{label} must include a hostname")
|
||||
|
||||
if hostname in CLOUD_METADATA_HOSTNAMES:
|
||||
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
||||
|
||||
if NUMERIC_IP_RE.match(hostname):
|
||||
raise ValueError(f"{label} must not use numeric-encoded IP addresses; use standard dotted-decimal notation")
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
except ValueError:
|
||||
return # symbolic hostname — out of scope by design (no DNS check)
|
||||
|
||||
effective = unwrap_ipv4_mapped(addr)
|
||||
|
||||
if effective in CLOUD_METADATA_IPS:
|
||||
raise ValueError(f"{label} must not point to a cloud metadata endpoint")
|
||||
|
||||
if effective.is_multicast or effective.is_unspecified:
|
||||
raise ValueError(f"{label} must not point to a multicast or unspecified address")
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveSta
|
|||
from backend.app.schemas.print_log import PrintLogResponse
|
||||
from backend.app.schemas.slicer import SliceRequest
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.design_settings import overrides_from_config
|
||||
from backend.app.utils.http import build_content_disposition
|
||||
from backend.app.utils.safe_path import safe_join_under
|
||||
from backend.app.utils.threemf_tools import (
|
||||
expand_to_project_slots,
|
||||
extract_embedded_presets_from_3mf,
|
||||
extract_nozzle_mapping_from_3mf,
|
||||
extract_project_filaments_from_3mf,
|
||||
|
|
@ -41,6 +43,9 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(prefix="/archives", tags=["archives"])
|
||||
|
||||
# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
|
||||
_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
|
||||
|
||||
|
||||
def _safe_filename(filename: str) -> str:
|
||||
"""Extract basename from a client-supplied filename, preventing path traversal.
|
||||
|
|
@ -119,6 +124,28 @@ def _match_timelapse_by_timestamp(
|
|||
return best_video, best_diff
|
||||
|
||||
|
||||
async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_id: int) -> set[str]:
|
||||
"""Video filenames already attached to another archive of this printer (#2704).
|
||||
|
||||
Lets the baseline diff drop a previous print's late-landing video from the
|
||||
candidate list without ordering the candidates — ordering could only be done
|
||||
on mtime or the filename timestamp, and both come from a clock the printer
|
||||
can't sync in LAN-only mode. ``attach_timelapse`` stores the video under the
|
||||
printer's own filename and the MP4 conversion keeps the stem, so the stem of
|
||||
``timelapse_path`` is what was claimed.
|
||||
"""
|
||||
if printer_id is None:
|
||||
return set()
|
||||
rows = await db.execute(
|
||||
select(PrintArchive.timelapse_path).where(
|
||||
PrintArchive.printer_id == printer_id,
|
||||
PrintArchive.id != exclude_archive_id,
|
||||
PrintArchive.timelapse_path.is_not(None),
|
||||
)
|
||||
)
|
||||
return {Path(p).stem for p in rows.scalars().all() if p}
|
||||
|
||||
|
||||
def _ensure_archive_visible(
|
||||
archive: PrintArchive | None,
|
||||
user: User | None,
|
||||
|
|
@ -570,6 +597,8 @@ async def list_archives_slim(
|
|||
PrintLogEntry.filament_color,
|
||||
PrintLogEntry.status,
|
||||
PrintLogEntry.cost,
|
||||
PrintLogEntry.energy_kwh,
|
||||
PrintLogEntry.energy_cost,
|
||||
PrintLogEntry.created_at,
|
||||
)
|
||||
.outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
|
|
@ -612,6 +641,8 @@ async def list_archives_slim(
|
|||
"started_at": r.started_at,
|
||||
"completed_at": r.completed_at,
|
||||
"cost": r.cost,
|
||||
"energy_kwh": r.energy_kwh,
|
||||
"energy_cost": r.energy_cost,
|
||||
"quantity": 1,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
|
|
@ -2262,9 +2293,11 @@ async def scan_timelapse(
|
|||
from backend.app.core.database import async_session
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import (
|
||||
delete_archived_timelapse,
|
||||
download_file_bytes_async,
|
||||
get_ftp_retry_settings,
|
||||
list_files_async,
|
||||
remote_file_settled,
|
||||
with_ftp_retry,
|
||||
)
|
||||
|
||||
|
|
@ -2314,18 +2347,48 @@ async def scan_timelapse(
|
|||
f for f in files if not f.get("is_directory") and f.get("name", "").lower().endswith((".mp4", ".avi"))
|
||||
]
|
||||
|
||||
# Strategy 0: snapshot diff against the baseline captured at print start
|
||||
# (#2704). This is the same comparison the automatic scan makes, and the
|
||||
# only one here that doesn't depend on the printer's clock — a printer in
|
||||
# LAN-only mode can't reach Bambu's NTP server, so the timestamps in both
|
||||
# the filename and the FTP mtime can be days out. One reporter's P1S was
|
||||
# six and a half days off, which defeats every strategy below.
|
||||
#
|
||||
# When a baseline exists it is authoritative and the clock-based strategies
|
||||
# are skipped entirely: they can only turn an honest "pick one yourself"
|
||||
# into a confident wrong answer. Those strategies stay for archives created
|
||||
# before the baseline was persisted.
|
||||
used_baseline = archive.timelapse_baseline is not None
|
||||
if used_baseline:
|
||||
baseline = set(archive.timelapse_baseline)
|
||||
async with async_session() as db:
|
||||
claimed = await _claimed_timelapse_stems(db, archive.printer_id, archive_id)
|
||||
candidates = [
|
||||
f for f in video_files if f.get("name", "") not in baseline and Path(f.get("name", "")).stem not in claimed
|
||||
]
|
||||
if len(candidates) == 1:
|
||||
matching_file = candidates[0]
|
||||
logger.info("Matched timelapse by print-start baseline: %s", matching_file.get("name"))
|
||||
elif candidates:
|
||||
# Ambiguous — offer only the plausible files instead of guessing.
|
||||
video_files = candidates
|
||||
logger.info("Baseline left %s unclaimed candidates for archive %s", len(candidates), archive_id)
|
||||
else:
|
||||
logger.info("Baseline shows no unclaimed new video on the printer for archive %s", archive_id)
|
||||
|
||||
# Strategy 1: Match by print name in filename
|
||||
for f in video_files:
|
||||
fname = f.get("name", "")
|
||||
if base_name.lower() in fname.lower():
|
||||
matching_file = f
|
||||
break
|
||||
if not used_baseline:
|
||||
for f in video_files:
|
||||
fname = f.get("name", "")
|
||||
if base_name.lower() in fname.lower():
|
||||
matching_file = f
|
||||
break
|
||||
|
||||
# Strategy 2: Match by timestamp proximity against print START time.
|
||||
# Bambu timelapse filename embeds the print start time in printer-local clock.
|
||||
# See _match_timelapse_by_timestamp for the offset-search rationale and why we
|
||||
# intentionally don't try to match filename against end time here.
|
||||
if not matching_file and archive.started_at:
|
||||
if not used_baseline and not matching_file and archive.started_at:
|
||||
candidate, diff = _match_timelapse_by_timestamp(video_files, archive.started_at)
|
||||
if candidate is not None:
|
||||
matching_file = candidate
|
||||
|
|
@ -2333,7 +2396,7 @@ async def scan_timelapse(
|
|||
|
||||
# Strategy 3: Use file modification time from FTP listing
|
||||
# This handles cases where printer's filename timestamp is wrong but file mtime is correct
|
||||
if not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
|
||||
if not used_baseline and not matching_file and (archive.started_at or archive.completed_at or archive.created_at):
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
_archive_start = archive.started_at
|
||||
|
|
@ -2361,7 +2424,7 @@ async def scan_timelapse(
|
|||
|
||||
# Strategy 4: If only one timelapse exists and archive was recently completed, use it
|
||||
# This handles cases where printer clock is wrong or timezone issues exist
|
||||
if not matching_file and len(video_files) == 1:
|
||||
if not used_baseline and not matching_file and len(video_files) == 1:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
archive_completed = archive.completed_at or archive.created_at
|
||||
|
|
@ -2411,6 +2474,7 @@ async def scan_timelapse(
|
|||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
expected_size=matching_file.get("size"),
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Download timelapse {matching_file['name']}",
|
||||
|
|
@ -2422,11 +2486,24 @@ async def scan_timelapse(
|
|||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
expected_size=matching_file.get("size"),
|
||||
)
|
||||
|
||||
if not timelapse_data:
|
||||
raise HTTPException(500, "Failed to download timelapse")
|
||||
|
||||
# Confirm the printer has finished writing before we commit to this file and
|
||||
# delete the original: matching the listing's size proves we got what it
|
||||
# said, not that the file was complete (#2704).
|
||||
if not await remote_file_settled(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
len(timelapse_data),
|
||||
printer_model=printer.model,
|
||||
):
|
||||
raise HTTPException(409, "The printer is still writing this video — try again in a moment")
|
||||
|
||||
# Attach in a fresh short session (the read session was released before FTP).
|
||||
async with async_session() as db:
|
||||
success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, matching_file["name"])
|
||||
|
|
@ -2434,6 +2511,17 @@ async def scan_timelapse(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to attach timelapse")
|
||||
|
||||
# Safe now, and only now: the transfer matched the size the listing reported
|
||||
# and the bytes are committed to the archive (#2704).
|
||||
await delete_archived_timelapse(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
verified=matching_file.get("size") is not None,
|
||||
printer_model=printer.model,
|
||||
printer_name=printer.name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "attached",
|
||||
"message": f"Timelapse '{matching_file['name']}' attached successfully",
|
||||
|
|
@ -2451,9 +2539,11 @@ async def select_timelapse(
|
|||
from backend.app.core.database import async_session
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import (
|
||||
delete_archived_timelapse,
|
||||
download_file_bytes_async,
|
||||
get_ftp_retry_settings,
|
||||
list_files_async,
|
||||
remote_file_settled,
|
||||
with_ftp_retry,
|
||||
)
|
||||
|
||||
|
|
@ -2476,6 +2566,7 @@ async def select_timelapse(
|
|||
# Find the file on the printer
|
||||
files = []
|
||||
remote_path = None
|
||||
expected_size = None
|
||||
for timelapse_dir in ["/timelapse", "/timelapse/video", "/record", "/recording"]:
|
||||
try:
|
||||
files = await list_files_async(
|
||||
|
|
@ -2484,6 +2575,7 @@ async def select_timelapse(
|
|||
for f in files:
|
||||
if f.get("name") == filename:
|
||||
remote_path = f.get("path") or f"{timelapse_dir}/{filename}"
|
||||
expected_size = f.get("size")
|
||||
break
|
||||
if remote_path:
|
||||
break
|
||||
|
|
@ -2504,6 +2596,7 @@ async def select_timelapse(
|
|||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
expected_size=expected_size,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Download timelapse {filename}",
|
||||
|
|
@ -2515,17 +2608,41 @@ async def select_timelapse(
|
|||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
expected_size=expected_size,
|
||||
)
|
||||
|
||||
if not timelapse_data:
|
||||
raise HTTPException(500, "Failed to download timelapse")
|
||||
|
||||
# Confirm the printer has finished writing before we commit to this file and
|
||||
# delete the original: matching the listing's size proves we got what it
|
||||
# said, not that the file was complete (#2704).
|
||||
if not await remote_file_settled(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
len(timelapse_data),
|
||||
printer_model=printer.model,
|
||||
):
|
||||
raise HTTPException(409, "The printer is still writing this video — try again in a moment")
|
||||
|
||||
# Attach in a fresh short session (the read session was released before FTP).
|
||||
async with async_session() as db:
|
||||
success = await ArchiveService(db).attach_timelapse(archive_id, timelapse_data, filename)
|
||||
if not success:
|
||||
raise HTTPException(500, "Failed to attach timelapse")
|
||||
|
||||
# Safe now, and only now: the transfer matched the size the listing reported
|
||||
# and the bytes are committed to the archive (#2704).
|
||||
await delete_archived_timelapse(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
verified=expected_size is not None,
|
||||
printer_model=printer.model,
|
||||
printer_name=printer.name,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "attached",
|
||||
"message": f"Timelapse '{filename}' attached successfully",
|
||||
|
|
@ -3457,11 +3574,23 @@ async def get_archive_plates(
|
|||
# Printer / process preset names the 3MF was prepared with — used by the
|
||||
# SliceModal to default its dropdowns (#1325).
|
||||
embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
|
||||
# Process settings the designer changed away from the stock preset (#2622),
|
||||
# offered in the SliceModal for a cross-printer re-slice. Same payload the
|
||||
# library plates endpoint returns — SliceModal reads one shape for both.
|
||||
design_overrides: list[dict] = []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
embedded_presets = extract_embedded_presets_from_3mf(zf)
|
||||
if _PROJECT_SETTINGS_PATH in namelist:
|
||||
try:
|
||||
design_overrides = [
|
||||
o._asdict()
|
||||
for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
|
||||
]
|
||||
except (ValueError, OSError, KeyError):
|
||||
design_overrides = []
|
||||
|
||||
# Find all plate gcode files to determine available plates
|
||||
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
|
||||
|
|
@ -3723,6 +3852,7 @@ async def get_archive_plates(
|
|||
"has_gcode": has_gcode,
|
||||
"embedded_printer": embedded_presets["printer"],
|
||||
"embedded_process": embedded_presets["process"],
|
||||
"design_overrides": design_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3775,6 +3905,7 @@ async def _try_preview_slice_filaments(
|
|||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.services.slice_preview import get_preview_filaments
|
||||
from backend.app.services.slicer_api import get_stall_timeout_seconds
|
||||
|
||||
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
|
||||
if preferred == "orcaslicer":
|
||||
|
|
@ -3800,6 +3931,7 @@ async def _try_preview_slice_filaments(
|
|||
file_name=file_path.name,
|
||||
api_url=api_url,
|
||||
request_id=request_id,
|
||||
timeout_seconds=await get_stall_timeout_seconds(db),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3808,6 +3940,7 @@ async def get_filament_requirements(
|
|||
archive_id: int,
|
||||
plate_id: int | None = None,
|
||||
request_id: str | None = None,
|
||||
full_slots: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
|
|
@ -3917,6 +4050,14 @@ async def get_filament_requirements(
|
|||
}
|
||||
)
|
||||
|
||||
# Re-slicing a source that already carries slice_info (#2712).
|
||||
# See library.py for the full rationale: the slice modal's list is
|
||||
# positional, so a source using only slot 4 must still present
|
||||
# four slots or the pick lands on slot 1. The print path keeps the
|
||||
# used-only list it depends on.
|
||||
if full_slots and filaments:
|
||||
filaments = expand_to_project_slots(zf, filaments)
|
||||
|
||||
# Unsliced project files: see library.py for full rationale.
|
||||
# Return the FULL project_settings.config slot list with a
|
||||
# used_in_plate flag derived from the preview slice; the
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from backend.app.core.auth import (
|
|||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.oidc_env import env_bool
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
|
||||
from backend.app.models.group import Group
|
||||
|
|
@ -122,7 +123,11 @@ def _local_login_env_bypass() -> bool:
|
|||
an install whose SSO provider is unreachable. Accepted truthy values:
|
||||
``true``, ``1``, ``yes`` (case-insensitive).
|
||||
"""
|
||||
return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
|
||||
# strict=False: this runs on the login/forgot-password request path, not at
|
||||
# startup. An unrecognized value must fall back to "off" (the safe default),
|
||||
# never raise -- a 500 on the recovery endpoint is the opposite of what this
|
||||
# bypass is for.
|
||||
return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
"""Camera streaming API endpoints for Bambu Lab printers."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -19,6 +22,7 @@ from backend.app.core.auth import (
|
|||
create_camera_stream_token,
|
||||
)
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.core.logging_filters import redact_url_credentials
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.user import User
|
||||
|
|
@ -45,12 +49,25 @@ from backend.app.services.camera_profiles import get_camera_profile
|
|||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/printers", tags=["camera"])
|
||||
|
||||
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580). A killed
|
||||
# ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take arbitrarily
|
||||
# long to exit — an unbounded post-kill wait() parked the fan-out stream
|
||||
# coroutine for 12 hours on a P2S, leaving every viewer attached to a stalled
|
||||
# broadcaster. Abandoning the wait is safe: cleanup_orphaned_streams' /proc scan
|
||||
# reaps any Bambu ffmpeg not attached to an active stream on its next pass.
|
||||
# Grace period for a SIGTERMed ffmpeg to shut down before we SIGKILL it. Only
|
||||
# reachable when ffmpeg genuinely ignores SIGTERM: _terminate_ffmpeg drains the
|
||||
# pipes first, and a drained ffmpeg exits in ~0.15s.
|
||||
_FFMPEG_TERM_TIMEOUT = 2.0
|
||||
|
||||
# Upper bound on waiting for a SIGKILLed ffmpeg to be reaped (#2580).
|
||||
#
|
||||
# The original diagnosis — "a killed ffmpeg stuck in uninterruptible I/O on a
|
||||
# dead RTSP socket" — was wrong, and this bound was capping a deadlock of our
|
||||
# own making rather than waiting out a stuck process. A process that survives
|
||||
# SIGKILL would have to be in uninterruptible sleep (state D); the ffmpeg seen
|
||||
# doing this was in state S, and its returncode was already set to -9 while
|
||||
# wait() was still blocked. The real cause was undrained pipes (see
|
||||
# _terminate_ffmpeg), which made this timeout fire on *every* camera close.
|
||||
#
|
||||
# Kept as a backstop now that the cause is fixed: it should no longer be
|
||||
# reachable, and if it ever is, abandoning the wait is still safe because
|
||||
# cleanup_orphaned_streams' /proc scan reaps any Bambu ffmpeg not attached to
|
||||
# an active stream on its next pass.
|
||||
_FFMPEG_KILL_TIMEOUT = 2.0
|
||||
|
||||
# Track active ffmpeg processes for cleanup
|
||||
|
|
@ -82,6 +99,14 @@ _disconnect_events: dict[str, asyncio.Event] = {}
|
|||
# Track last frame time per stream_id (not just per printer_id) for stale detection
|
||||
_stream_last_frame_times: dict[str, float] = {}
|
||||
|
||||
# How much of a streaming ffmpeg's stderr to retain: enough for the input
|
||||
# analysis plus a burst of errors, capped so a long-running stream can't grow it.
|
||||
_FFMPEG_STDERR_TAIL_BYTES = 16384
|
||||
|
||||
# Live stderr collectors by pid — see _FfmpegStderrTail. Present means "this
|
||||
# process's stderr already has a reader; do not open a second one".
|
||||
_stderr_tails: dict[int, "_FfmpegStderrTail"] = {}
|
||||
|
||||
|
||||
def get_buffered_frame(printer_id: int) -> bytes | None:
|
||||
"""Get the last buffered frame for a printer from an active stream.
|
||||
|
|
@ -193,8 +218,6 @@ async def generate_chamber_mjpeg_stream(
|
|||
|
||||
# Save frame to buffer for photo capture and track timestamp
|
||||
if printer_id is not None:
|
||||
import time
|
||||
|
||||
_last_frames[printer_id] = frame
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
|
||||
|
|
@ -226,10 +249,7 @@ async def generate_chamber_mjpeg_stream(
|
|||
_stream_last_frame_times.pop(stream_id, None)
|
||||
|
||||
# Clean up frame buffer and timestamps
|
||||
if printer_id is not None:
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
_release_printer_frame_state(printer_id)
|
||||
|
||||
# Close the connection
|
||||
try:
|
||||
|
|
@ -240,14 +260,127 @@ async def generate_chamber_mjpeg_stream(
|
|||
logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
|
||||
|
||||
|
||||
def _new_fanout_stream_id(printer_id: int) -> str:
|
||||
"""Registry key for one fan-out stream INSTANCE, not for the printer.
|
||||
|
||||
A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
|
||||
printer shared one key, so a departing generator's cleanup removed the entry
|
||||
its successor had just registered. The external-camera path already carries a
|
||||
per-instance suffix for exactly this reason (#2675); this gives the fan-out
|
||||
path the same property.
|
||||
|
||||
The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
|
||||
``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
|
||||
scanning for it — so the suffix goes on the end.
|
||||
"""
|
||||
return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
def live_frame_for_capture(printer_id: int) -> tuple[bool, bytes | None]:
|
||||
"""Should a one-shot capture stand down for the live view, and to what frame?
|
||||
|
||||
Returns ``(defer, frame)``. ``defer`` True means DO NOT open a capture of
|
||||
your own: use ``frame`` when it isn't None, and otherwise skip this attempt
|
||||
rather than competing.
|
||||
|
||||
Both camera kinds allow exactly one reader — Bambu firmware permits one
|
||||
connection, and a USB camera permits one V4L2 handle — so a capture that
|
||||
races the live view doesn't degrade, it fails outright. #2707 measured 0 of
|
||||
87 and 0 of 105 layer-timelapse captures on prints watched throughout, and
|
||||
finish photos going out with no image attached.
|
||||
|
||||
Skipping when the buffer is momentarily empty (stream starting, mid-
|
||||
reconnect) rather than falling through to a capture is the #1348 rule:
|
||||
opening a competing handle kicks the viewer off, which is a worse outcome
|
||||
than missing one frame.
|
||||
"""
|
||||
if not is_stream_active(printer_id):
|
||||
return False, None
|
||||
return True, _last_frames.get(printer_id)
|
||||
|
||||
|
||||
def _release_printer_frame_state(printer_id: int | None) -> None:
|
||||
"""Drop a printer's buffered frame and timings — unless a stream still owns them.
|
||||
|
||||
These three dicts are keyed by printer, not by stream, so a departing
|
||||
generator must not clear them while a newer stream for the same printer is
|
||||
running. That used to happen routinely: stream ids were per-printer, so a
|
||||
predecessor's cleanup wiped its successor's state, leaving
|
||||
``is_stream_active()`` False with a viewer attached (which is exactly what
|
||||
the #1348 / #1271 guards read before deciding whether it is safe to open a
|
||||
second camera connection), the janitor free to reap the live ffmpeg as an
|
||||
orphan, and snapshots without a frame to reuse.
|
||||
|
||||
Call this AFTER removing the departing stream's own key, so the check
|
||||
reports on other streams rather than on the caller.
|
||||
"""
|
||||
if printer_id is None or is_stream_active(printer_id):
|
||||
return
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
|
||||
|
||||
async def _drain_pipe(reader) -> None:
|
||||
"""Read a subprocess pipe to EOF and discard, so it can never block.
|
||||
|
||||
Best-effort by design: any read failure means we cannot drain further, and
|
||||
the caller is tearing the process down regardless.
|
||||
"""
|
||||
if reader is None:
|
||||
return
|
||||
try:
|
||||
while await reader.read(65536):
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — teardown must not fail on a dying pipe
|
||||
return
|
||||
|
||||
|
||||
async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str | None = None) -> None:
|
||||
"""Terminate an ffmpeg process gracefully, then kill if needed."""
|
||||
"""Terminate an ffmpeg process gracefully, then kill if needed.
|
||||
|
||||
Drains stdout/stderr throughout, which is load-bearing rather than hygiene.
|
||||
ffmpeg is spawned with both as pipes, and every caller of this has already
|
||||
stopped reading stdout — so by the time we get here ffmpeg is typically
|
||||
blocked in write() on a full 64 KiB pipe. Two things then go wrong:
|
||||
|
||||
* SIGTERM cannot be acted on. ffmpeg's handler only sets a flag that its
|
||||
main loop polls, and a loop blocked in write() never reaches the check,
|
||||
so the whole grace period is dead time.
|
||||
* SIGKILL does kill it, but wait() cannot observe that. asyncio resolves
|
||||
Process.wait()'s waiter through BaseSubprocessTransport._try_finish(),
|
||||
which requires every pipe transport to report disconnected; paused,
|
||||
unread pipes never reach EOF, so wait() blocks with returncode already
|
||||
set. That is what made the "did not exit within Ns of SIGKILL" error
|
||||
fire on every single camera close, and unbounded it was the 12-hour
|
||||
hang in #2580.
|
||||
|
||||
Draining fixes both: SIGTERM becomes actionable and the exit observable.
|
||||
Measured on an H2D: 4.0s of dead time per close before, ~0.15s after —
|
||||
which matters because the printer allows exactly one camera connection,
|
||||
so every one of those seconds was a connection nobody could use.
|
||||
|
||||
Discarding what we drain is deliberate. The stream loop already reads
|
||||
stderr on its error paths (_read_ffmpeg_stderr), and it does so before
|
||||
calling this, so nothing diagnostic is lost.
|
||||
"""
|
||||
if process.returncode is not None:
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
return # Already dead
|
||||
|
||||
drainers = [asyncio.create_task(_drain_pipe(process.stdout))]
|
||||
# A streaming ffmpeg's stderr already has a reader (_FfmpegStderrTail), and
|
||||
# it keeps draining right through teardown, which is all we need here. Adding
|
||||
# a second reader would race it — asyncio rejects concurrent reads on one
|
||||
# StreamReader — so only drain stderr when nobody else owns it.
|
||||
if process.pid not in _stderr_tails:
|
||||
drainers.append(asyncio.create_task(_drain_pipe(process.stderr)))
|
||||
try:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=2.0)
|
||||
await asyncio.wait_for(process.wait(), timeout=_FFMPEG_TERM_TIMEOUT)
|
||||
except TimeoutError:
|
||||
logger.warning("ffmpeg didn't terminate gracefully, killing (stream_id=%s)", stream_id)
|
||||
process.kill()
|
||||
|
|
@ -256,7 +389,8 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
|
|||
except TimeoutError:
|
||||
# Do NOT keep waiting (#2580): the caller is the stream
|
||||
# generator, and blocking here pins the fan-out pump forever.
|
||||
# The orphan janitor reaps the process later.
|
||||
# The orphan janitor reaps the process later. With the pipes
|
||||
# drained this should be unreachable — see _FFMPEG_KILL_TIMEOUT.
|
||||
logger.error(
|
||||
"ffmpeg did not exit within %.1fs of SIGKILL; abandoning wait (stream_id=%s)",
|
||||
_FFMPEG_KILL_TIMEOUT,
|
||||
|
|
@ -266,7 +400,11 @@ async def _terminate_ffmpeg(process: asyncio.subprocess.Process, stream_id: str
|
|||
pass # Already dead
|
||||
except OSError as e:
|
||||
logger.warning("Error terminating ffmpeg: %s", e)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
finally:
|
||||
for drainer in drainers:
|
||||
drainer.cancel()
|
||||
await asyncio.gather(*drainers, return_exceptions=True)
|
||||
_spawned_ffmpeg_pids.pop(process.pid, None)
|
||||
|
||||
|
||||
def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
||||
|
|
@ -276,9 +414,15 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
|||
any actual error message. Logging the full banner on every retry floods
|
||||
the log (hundreds of lines per failed stream). This filter drops the
|
||||
banner and caps output at the last 10 meaningful lines.
|
||||
|
||||
Credentials are masked here rather than at each ``logger`` call because
|
||||
this is the one funnel every stderr log in this module passes through.
|
||||
ffmpeg echoes the RTSP input URL back in its ``Input #0`` line, which
|
||||
carries the printer access code.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
text = redact_url_credentials(text) or ""
|
||||
banner_prefixes = (
|
||||
"ffmpeg version ",
|
||||
" built with ",
|
||||
|
|
@ -296,6 +440,82 @@ def _summarize_ffmpeg_stderr(text: str | None) -> str:
|
|||
return "\n".join(meaningful[-10:])
|
||||
|
||||
|
||||
class _FfmpegStderrTail:
|
||||
"""Owns a long-lived ffmpeg's stderr: drains it continuously, keeps the tail.
|
||||
|
||||
Reading stderr only when something has already gone wrong leaves a pipe
|
||||
nobody reads for the whole life of the stream. ffmpeg writes its banner, the
|
||||
input analysis and then a progress line at a steady rate, so a 64 KiB pipe
|
||||
fills eventually and ffmpeg blocks writing to it — at which point it stops
|
||||
producing frames, the stream's own read timeout fires, and the log says
|
||||
"RTSP read timeout" with no hint that we starved it ourselves.
|
||||
|
||||
How long that takes is unmeasured and may be a long time: one H2D upstream
|
||||
ran 21m36s continuously without stalling, so this is a bounded resource
|
||||
being treated as unbounded rather than an observed failure. Draining removes
|
||||
the ceiling either way, and the tail is *better* diagnostic material than
|
||||
the old on-demand read: it holds ffmpeg's most recent output at the moment
|
||||
things went wrong, where reading the buffered pipe returned whatever was
|
||||
printed first (usually the startup banner, which the summariser then strips).
|
||||
|
||||
Registers itself in ``_stderr_tails`` so the two other readers of this pipe
|
||||
can defer to it — asyncio raises if two coroutines read one StreamReader
|
||||
concurrently. See ``_read_ffmpeg_stderr`` and ``_terminate_ffmpeg``.
|
||||
"""
|
||||
|
||||
def __init__(self, process: asyncio.subprocess.Process) -> None:
|
||||
self._process = process
|
||||
self._buffer = bytearray()
|
||||
self._task: asyncio.Task | None = None
|
||||
if process.stderr is None:
|
||||
return
|
||||
self._task = asyncio.create_task(self._pump())
|
||||
_stderr_tails[process.pid] = self
|
||||
|
||||
async def _pump(self) -> None:
|
||||
reader = self._process.stderr
|
||||
try:
|
||||
while True:
|
||||
chunk = await reader.read(8192)
|
||||
if not chunk:
|
||||
return # EOF — ffmpeg has exited
|
||||
self._buffer.extend(chunk)
|
||||
excess = len(self._buffer) - _FFMPEG_STDERR_TAIL_BYTES
|
||||
if excess > 0:
|
||||
del self._buffer[:excess]
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 — a broken pipe just ends the tail
|
||||
return
|
||||
|
||||
def text(self) -> str | None:
|
||||
"""The retained tail, summarised. None when nothing was captured.
|
||||
|
||||
Goes through _summarize_ffmpeg_stderr like every other stderr log in
|
||||
this module: ffmpeg echoes its input URL, which carries the access code.
|
||||
"""
|
||||
if not self._buffer:
|
||||
return None
|
||||
return _summarize_ffmpeg_stderr(self._buffer.decode(errors="replace")) or None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Stop draining and release ownership of the pipe. Idempotent.
|
||||
|
||||
Awaits the cancelled pump rather than firing and forgetting, so the task
|
||||
is finished before the caller moves on — an abandoned pending task
|
||||
becomes an "unraisable exception" warning at an arbitrary later point,
|
||||
usually during interpreter or loop teardown.
|
||||
"""
|
||||
task, self._task = self._task, None
|
||||
if _stderr_tails.get(self._process.pid) is self:
|
||||
del _stderr_tails[self._process.pid]
|
||||
if task is None:
|
||||
return
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None:
|
||||
"""Read whatever ffmpeg has written to stderr so far (best-effort).
|
||||
|
||||
|
|
@ -306,8 +526,18 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
|
|||
banner + stream-analysis lines ffmpeg already printed. Reading in bounded
|
||||
chunks returns the buffered output promptly whether or not ffmpeg has
|
||||
exited. Returns the content with ffmpeg's boilerplate banner stripped.
|
||||
|
||||
When a _FfmpegStderrTail owns this process's stderr — every streaming
|
||||
ffmpeg — its retained tail is returned instead. Reading the pipe here as
|
||||
well would race that collector, and asyncio refuses two concurrent readers
|
||||
on one StreamReader outright.
|
||||
"""
|
||||
if not process or not process.stderr:
|
||||
if not process:
|
||||
return None
|
||||
tail = _stderr_tails.get(getattr(process, "pid", None))
|
||||
if tail is not None:
|
||||
return tail.text()
|
||||
if not process.stderr:
|
||||
return None
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
|
|
@ -428,6 +658,7 @@ async def generate_rtsp_mjpeg_stream(
|
|||
jpeg_end = b"\xff\xd9"
|
||||
reconnect_count = 0
|
||||
process = None
|
||||
stderr_tail: _FfmpegStderrTail | None = None
|
||||
got_any_frames = False
|
||||
|
||||
try:
|
||||
|
|
@ -480,6 +711,14 @@ async def generate_rtsp_mjpeg_stream(
|
|||
reconnect_count += 1
|
||||
continue
|
||||
|
||||
# Take ownership of stderr for the life of this process. Started
|
||||
# only after the immediate-failure check above, which reads the pipe
|
||||
# directly (correct there: the process is already dead, so
|
||||
# read-to-EOF returns at once and cannot be raced by a collector).
|
||||
# Nothing is lost by starting late — the banner ffmpeg printed in the
|
||||
# meantime is still sitting in the pipe.
|
||||
stderr_tail = _FfmpegStderrTail(process)
|
||||
|
||||
# Read JPEG frames from ffmpeg stdout
|
||||
buffer = b""
|
||||
stream_ended = False
|
||||
|
|
@ -523,8 +762,6 @@ async def generate_rtsp_mjpeg_stream(
|
|||
got_any_frames = True
|
||||
|
||||
if printer_id is not None:
|
||||
import time
|
||||
|
||||
_last_frames[printer_id] = frame
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
if stream_id:
|
||||
|
|
@ -555,6 +792,12 @@ async def generate_rtsp_mjpeg_stream(
|
|||
|
||||
# Clean up this ffmpeg process before reconnecting or exiting
|
||||
await _terminate_ffmpeg(process, stream_id)
|
||||
# Released after teardown, not before: _terminate_ffmpeg deliberately
|
||||
# leaves stderr to this collector, which has to keep draining while
|
||||
# the process is stopped or wait() can't observe the exit.
|
||||
if stderr_tail is not None:
|
||||
await stderr_tail.aclose()
|
||||
stderr_tail = None
|
||||
process = None
|
||||
|
||||
if client_gone:
|
||||
|
|
@ -597,15 +840,16 @@ async def generate_rtsp_mjpeg_stream(
|
|||
_stream_last_frame_times.pop(stream_id, None)
|
||||
|
||||
# Clean up frame buffer and timestamps
|
||||
if printer_id is not None:
|
||||
_last_frames.pop(printer_id, None)
|
||||
_last_frame_times.pop(printer_id, None)
|
||||
_stream_start_times.pop(printer_id, None)
|
||||
_release_printer_frame_state(printer_id)
|
||||
|
||||
if process:
|
||||
await _terminate_ffmpeg(process, stream_id)
|
||||
logger.info("Camera stream stopped for %s (stream_id=%s)", ip_address, stream_id)
|
||||
|
||||
# Same order as in the loop: terminate first, then release stderr.
|
||||
if stderr_tail is not None:
|
||||
await stderr_tail.aclose()
|
||||
|
||||
# Shut down the TLS proxy
|
||||
proxy_server.close()
|
||||
await proxy_server.wait_closed()
|
||||
|
|
@ -665,8 +909,11 @@ async def camera_stream(
|
|||
|
||||
# Check for external camera first
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
import time
|
||||
|
||||
# NB: no `import time` / `import uuid` here, and don't reintroduce them.
|
||||
# A local import anywhere in this function makes the name function-local
|
||||
# for the WHOLE function, so the RTSP/chamber path below — which never
|
||||
# executes this branch — would raise UnboundLocalError on any printer
|
||||
# without an external camera. Both are imported at module level.
|
||||
from backend.app.services.external_camera import generate_mjpeg_stream
|
||||
|
||||
# Limit external camera FPS to reduce browser load
|
||||
|
|
@ -675,22 +922,79 @@ async def camera_stream(
|
|||
"Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
|
||||
)
|
||||
|
||||
# Register the stream into the SAME registries the RTSP/chamber paths use
|
||||
# (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
|
||||
# a leaked ffmpeg holding a USB device open. Before this, external streams
|
||||
# only tracked _active_external_streams and were structurally invisible to
|
||||
# both the stop endpoint and the janitor. The stream_id keeps the
|
||||
# `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
|
||||
# concurrent viewers of one printer don't clobber each other's entry.
|
||||
stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
|
||||
stop_event = asyncio.Event()
|
||||
_disconnect_events[stream_id] = stop_event
|
||||
# Track stream start
|
||||
_stream_start_times[printer_id] = time.time()
|
||||
_active_external_streams.add(printer_id)
|
||||
|
||||
# Mutable holder so the wrapper's finally can unregister whatever process
|
||||
# is currently registered (the RTSP path may respawn across reconnects).
|
||||
current_proc: dict[str, asyncio.subprocess.Process] = {}
|
||||
|
||||
def _register_external_process(proc: asyncio.subprocess.Process) -> None:
|
||||
prev = current_proc.get("proc")
|
||||
if prev is not None and prev.pid != proc.pid:
|
||||
_spawned_ffmpeg_pids.pop(prev.pid, None)
|
||||
current_proc["proc"] = proc
|
||||
_active_streams[stream_id] = proc
|
||||
_spawned_ffmpeg_pids[proc.pid] = time.time()
|
||||
_stream_last_frame_times[stream_id] = time.time()
|
||||
|
||||
def _publish_external_frame(frame: bytes) -> None:
|
||||
"""Make the live frame reusable by one-shot consumers (#2707).
|
||||
|
||||
Only the built-in camera paths populated _last_frames, so every
|
||||
external-camera consumer — layer timelapse, finish photo, Obico,
|
||||
plate check — found an empty buffer and opened its own handle on a
|
||||
device that allows exactly one reader, which simply failed while a
|
||||
viewer was attached. Raw frame, not the multipart-wrapped chunk the
|
||||
generator yields, because that is what those consumers expect.
|
||||
"""
|
||||
_last_frames[printer_id] = frame
|
||||
|
||||
async def external_stream_wrapper():
|
||||
"""Wrap external stream to track start/stop and update frame times."""
|
||||
try:
|
||||
async for frame in generate_mjpeg_stream(
|
||||
printer.external_camera_url, printer.external_camera_type, fps
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type,
|
||||
fps,
|
||||
on_process=_register_external_process,
|
||||
on_frame=_publish_external_frame,
|
||||
stop_event=stop_event,
|
||||
):
|
||||
# generate_mjpeg_stream already handles rate limiting;
|
||||
# just track frame times for stall detection
|
||||
_last_frame_times[printer_id] = time.time()
|
||||
# track frame times (per-printer + per-stream) for stall detection
|
||||
now = time.time()
|
||||
_last_frame_times[printer_id] = now
|
||||
_stream_last_frame_times[stream_id] = now
|
||||
yield frame
|
||||
finally:
|
||||
# Best-effort unregister. If an abrupt disconnect skips this
|
||||
# finally, the registry entries persist — which is exactly what
|
||||
# lets the stop endpoint / janitor reap the leaked process.
|
||||
stop_event.set()
|
||||
proc = current_proc.get("proc")
|
||||
if proc is not None:
|
||||
_spawned_ffmpeg_pids.pop(proc.pid, None)
|
||||
_active_streams.pop(stream_id, None)
|
||||
_disconnect_events.pop(stream_id, None)
|
||||
_stream_last_frame_times.pop(stream_id, None)
|
||||
_active_external_streams.discard(printer_id)
|
||||
# Now that this path publishes a buffered frame, it has to
|
||||
# retract it too — ownership-checked, so a concurrent viewer of
|
||||
# the same printer keeps its own. Also clears the per-printer
|
||||
# timings this path used to leave behind.
|
||||
_release_printer_frame_state(printer_id)
|
||||
logger.info("External camera stream ended for printer %s", printer_id)
|
||||
|
||||
return StreamingResponse(
|
||||
|
|
@ -722,8 +1026,6 @@ async def camera_stream(
|
|||
# attached — otherwise /camera/status would report stream_uptime jumping
|
||||
# backward whenever a second viewer joins. The upstream generator's
|
||||
# finally clears this entry when the upstream actually ends.
|
||||
import time
|
||||
|
||||
_stream_start_times.setdefault(printer_id, time.time())
|
||||
|
||||
# Fan-out broadcaster (#1089): one upstream connection per printer, shared
|
||||
|
|
@ -736,7 +1038,7 @@ async def camera_stream(
|
|||
# broadcaster. Concurrent viewers share that rate; new viewers after
|
||||
# teardown create a fresh broadcaster at their requested fps.
|
||||
fanout_key = f"printer-{printer_id}"
|
||||
upstream_stream_id = f"{printer_id}-fanout"
|
||||
upstream_stream_id = _new_fanout_stream_id(printer_id)
|
||||
|
||||
def _factory(disconnect_event: asyncio.Event):
|
||||
# Re-bind locals into the closure so the async generator below sees
|
||||
|
|
@ -1529,9 +1831,14 @@ async def delete_reference(
|
|||
|
||||
|
||||
def _scan_bambu_ffmpeg_pids() -> list[int]:
|
||||
"""Scan /proc for ffmpeg processes with Bambu RTSP URLs.
|
||||
"""Scan /proc for ffmpeg processes that are ours.
|
||||
|
||||
Two shapes are matched, both unambiguously Bambuddy's:
|
||||
- Bambu RTSP: no other software connects to ``rtsp(s)://bblp:``.
|
||||
- External USB (V4L2): an ffmpeg spawned with ``-f v4l2`` is our USB camera
|
||||
stream (#2675). Only orphans are killed — the caller excludes PIDs still in
|
||||
``_active_streams``, so a live USB stream (now registered there) is spared.
|
||||
|
||||
These are definitely ours — no other software connects to rtsp(s)://bblp:.
|
||||
This catches orphans that survive app restarts and are not in any tracking dict.
|
||||
"""
|
||||
import os
|
||||
|
|
@ -1544,8 +1851,11 @@ def _scan_bambu_ffmpeg_pids() -> list[int]:
|
|||
try:
|
||||
with open(f"/proc/{entry}/cmdline", "rb") as f:
|
||||
cmdline = f.read()
|
||||
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct)
|
||||
if b"ffmpeg" in cmdline and (b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline):
|
||||
if b"ffmpeg" not in cmdline:
|
||||
continue
|
||||
# Match both rtsp:// (via TLS proxy) and rtsps:// (direct), plus
|
||||
# the `-f v4l2` input flag our USB camera command always carries.
|
||||
if b"rtsp://bblp:" in cmdline or b"rtsps://bblp:" in cmdline or b"v4l2" in cmdline:
|
||||
pids.append(int(entry))
|
||||
except (OSError, PermissionError, ValueError):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
|
|||
from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.github_backup import (
|
||||
CloudAccountCounts,
|
||||
GitHubBackupConfigCreate,
|
||||
GitHubBackupConfigResponse,
|
||||
GitHubBackupConfigUpdate,
|
||||
|
|
@ -49,7 +50,21 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
|
|||
|
||||
Used by POST and PATCH /config so a backup configuration can never be
|
||||
saved against a public repository.
|
||||
|
||||
The URL is policy-checked first: the Gitea and Forgejo backends derive
|
||||
their API base from this value (``get_api_base``) and then request it with
|
||||
the supplied token, so an unchecked repository_url is an outbound fetch to
|
||||
an operator-supplied host. A self-hosted Gitea on the LAN is the normal
|
||||
case, so the LAN-service tier applies — this only rules out the targets
|
||||
that are wrong under any topology.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
assert_safe_lan_service_url(repo_url, label="Repository URL")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
result = await github_backup_service.test_connection(repo_url, token, provider=provider)
|
||||
if not result.get("success"):
|
||||
message = result.get("message") or "Connection test failed"
|
||||
|
|
@ -61,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
|
|||
raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
|
||||
|
||||
|
||||
async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
|
||||
"""How many Bambu / Orca accounts a backup would collect from.
|
||||
|
||||
Asks the collector itself rather than re-deriving the rule, so the number
|
||||
the UI gates on can't drift from the number the backup actually uses
|
||||
(#2717). Counts only — never who.
|
||||
"""
|
||||
try:
|
||||
bambu, orca = await github_backup_service.cloud_accounts(db)
|
||||
return len(bambu), len(orca)
|
||||
except Exception:
|
||||
# A settings page must still render when a credential store is
|
||||
# unreadable; the toggle simply shows as unavailable.
|
||||
logger.warning("Failed to count connected cloud accounts", exc_info=True)
|
||||
return 0, 0
|
||||
|
||||
|
||||
@router.get("/cloud-accounts", response_model=CloudAccountCounts)
|
||||
async def get_cloud_accounts(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
|
||||
):
|
||||
"""How many cloud accounts the Cloud Profiles category would collect from.
|
||||
|
||||
Its own endpoint rather than a field on ``/config``, because the settings
|
||||
form needs this before any config exists — ``/config`` answers ``null``
|
||||
until the first save, which would leave the toggle disabled during the
|
||||
very setup it's part of.
|
||||
"""
|
||||
bambu, orca = await _count_cloud_accounts(db)
|
||||
return CloudAccountCounts(bambu=bambu, orca=orca)
|
||||
|
||||
|
||||
def _config_to_response(config: GitHubBackupConfig) -> dict:
|
||||
"""Convert config model to response dict."""
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -148,6 +148,9 @@ async def set_kprofile(
|
|||
)
|
||||
if not delete_success:
|
||||
raise HTTPException(500, "Failed to delete existing K-profile for edit")
|
||||
ok, detail = await client.await_cali_ack(delete_success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before adding
|
||||
await asyncio.sleep(0.5)
|
||||
|
|
@ -179,6 +182,13 @@ async def set_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile command")
|
||||
|
||||
# The printer answers extrusion_cali_set with result/reason, echoing our
|
||||
# sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
|
||||
# so a rejected write was reported to the user as saved.
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
|
||||
|
||||
message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
|
||||
return {"success": True, "message": message}
|
||||
|
||||
|
|
@ -239,6 +249,10 @@ async def set_kprofiles_batch(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profiles batch command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
|
||||
|
||||
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
|
||||
|
||||
|
||||
|
|
@ -283,6 +297,10 @@ async def delete_kprofile(
|
|||
if not success:
|
||||
raise HTTPException(500, "Failed to send K-profile delete command")
|
||||
|
||||
ok, detail = await client.await_cali_ack(success)
|
||||
if not ok:
|
||||
raise HTTPException(500, f"Printer rejected the delete: {detail}")
|
||||
|
||||
# Wait for printer to process the delete before frontend refetches
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,10 +64,16 @@ from backend.app.schemas.library import (
|
|||
)
|
||||
from backend.app.schemas.slicer import SliceRequest, SliceResponse
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
from backend.app.services.design_settings import (
|
||||
apply_design_overrides,
|
||||
extract_design_process_overrides,
|
||||
overrides_from_config,
|
||||
)
|
||||
from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
|
||||
from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
|
||||
from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
|
||||
from backend.app.utils.threemf_tools import (
|
||||
expand_to_project_slots,
|
||||
extract_embedded_presets_from_3mf,
|
||||
extract_nozzle_mapping_from_3mf,
|
||||
extract_project_filaments_from_3mf,
|
||||
|
|
@ -77,6 +83,9 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(prefix="/library", tags=["library"])
|
||||
|
||||
# Path of the embedded slicer config inside a BambuStudio/OrcaSlicer 3MF.
|
||||
_PROJECT_SETTINGS_PATH = "Metadata/project_settings.config"
|
||||
|
||||
|
||||
def _ensure_library_file_visible(
|
||||
library_file: LibraryFile | None,
|
||||
|
|
@ -751,24 +760,34 @@ async def list_folders(
|
|||
)
|
||||
file_counts = dict(file_counts_result.all())
|
||||
|
||||
# Latest immediate-child file activity per folder (#1770). Sibling of the
|
||||
# file_counts subquery — same WHERE clause, MAX(updated_at) instead of
|
||||
# COUNT(id). Subfolder descent is not aggregated here; the frontend's
|
||||
# "sort by recent activity" mode is satisfied by immediate-parent bubble.
|
||||
# Latest immediate-child file activity per folder (#1770/#2680). Real on-disk
|
||||
# mtime when we have it (external scans populate ``fs_modified_at``), else the
|
||||
# DB ``updated_at`` — COALESCE so external rows scanned before this field
|
||||
# existed, and internal uploads, still contribute a signal. This is the
|
||||
# per-folder *leaf* value; subtree descent is aggregated recursively below.
|
||||
latest_file_activity_result = await db.execute(
|
||||
select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
|
||||
select(
|
||||
LibraryFile.folder_id,
|
||||
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
|
||||
)
|
||||
.where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
|
||||
.group_by(LibraryFile.folder_id)
|
||||
)
|
||||
latest_file_activity = dict(latest_file_activity_result.all())
|
||||
|
||||
# Build tree structure
|
||||
# Build tree structure. Each folder's initial ``latest_activity_at`` is its own
|
||||
# leaf activity: the newer of its real directory mtime (fallback updated_at)
|
||||
# and its immediate files' mtime. The recursive bubble below then rolls each
|
||||
# subtree's newest descendant up to its ancestors (#2680 — sorting must match
|
||||
# ``ls -t`` recursively, so a freshly-added deep file lifts every parent).
|
||||
folder_map = {}
|
||||
root_folders = []
|
||||
|
||||
for folder, project_name, archive_name in rows:
|
||||
own_activity = folder.fs_modified_at or folder.updated_at
|
||||
latest_file = latest_file_activity.get(folder.id)
|
||||
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
|
||||
if latest_file is not None and latest_file > own_activity:
|
||||
own_activity = latest_file
|
||||
folder_item = FolderTreeItem(
|
||||
id=folder.id,
|
||||
name=folder.name,
|
||||
|
|
@ -781,7 +800,7 @@ async def list_folders(
|
|||
external_path=folder.external_path,
|
||||
external_readonly=folder.external_readonly,
|
||||
file_count=file_counts.get(folder.id, 0),
|
||||
latest_activity_at=latest_activity_at,
|
||||
latest_activity_at=own_activity,
|
||||
children=[],
|
||||
)
|
||||
folder_map[folder.id] = folder_item
|
||||
|
|
@ -794,6 +813,28 @@ async def list_folders(
|
|||
elif folder.parent_id in folder_map:
|
||||
folder_map[folder.parent_id].children.append(folder_item)
|
||||
|
||||
# Recursive newest-descendant bubble (#2680). Post-order: a folder's activity
|
||||
# becomes the max of its own leaf activity and every descendant's, so sorting
|
||||
# the tree by ``latest_activity_at`` surfaces the branch with the most recent
|
||||
# activity anywhere inside it. Iterative stack keeps deep external mounts off
|
||||
# Python's recursion limit.
|
||||
def _bubble(root: FolderTreeItem) -> None:
|
||||
order: list[FolderTreeItem] = []
|
||||
stack = [root]
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
order.append(node)
|
||||
stack.extend(node.children)
|
||||
for node in reversed(order): # deepest first
|
||||
for child in node.children:
|
||||
if child.latest_activity_at is not None and (
|
||||
node.latest_activity_at is None or child.latest_activity_at > node.latest_activity_at
|
||||
):
|
||||
node.latest_activity_at = child.latest_activity_at
|
||||
|
||||
for root in root_folders:
|
||||
_bubble(root)
|
||||
|
||||
return root_folders
|
||||
|
||||
|
||||
|
|
@ -819,11 +860,12 @@ async def get_folders_by_project(
|
|||
|
||||
folders = []
|
||||
for folder, project_name in rows:
|
||||
# Get file count + latest file activity (#1770) in one trip
|
||||
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
|
||||
# the real on-disk mtime (external scans), fall back to the DB updated_at.
|
||||
agg_result = await db.execute(
|
||||
select(
|
||||
func.count(LibraryFile.id),
|
||||
func.max(LibraryFile.updated_at),
|
||||
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
|
||||
).where(
|
||||
LibraryFile.folder_id == folder.id,
|
||||
LibraryFile.deleted_at.is_(None),
|
||||
|
|
@ -831,7 +873,8 @@ async def get_folders_by_project(
|
|||
)
|
||||
file_count, latest_file = agg_result.one()
|
||||
file_count = file_count or 0
|
||||
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
|
||||
own_activity = folder.fs_modified_at or folder.updated_at
|
||||
latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
|
||||
|
||||
folders.append(
|
||||
FolderResponse(
|
||||
|
|
@ -878,11 +921,12 @@ async def get_folders_by_archive(
|
|||
|
||||
folders = []
|
||||
for folder, archive_name in rows:
|
||||
# Get file count + latest file activity (#1770) in one trip
|
||||
# Get file count + latest file activity (#1770/#2680) in one trip. Prefer
|
||||
# the real on-disk mtime (external scans), fall back to the DB updated_at.
|
||||
agg_result = await db.execute(
|
||||
select(
|
||||
func.count(LibraryFile.id),
|
||||
func.max(LibraryFile.updated_at),
|
||||
func.max(func.coalesce(LibraryFile.fs_modified_at, LibraryFile.updated_at)),
|
||||
).where(
|
||||
LibraryFile.folder_id == folder.id,
|
||||
LibraryFile.deleted_at.is_(None),
|
||||
|
|
@ -890,7 +934,8 @@ async def get_folders_by_archive(
|
|||
)
|
||||
file_count, latest_file = agg_result.one()
|
||||
file_count = file_count or 0
|
||||
latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
|
||||
own_activity = folder.fs_modified_at or folder.updated_at
|
||||
latest_activity_at = max(own_activity, latest_file) if latest_file is not None else own_activity
|
||||
|
||||
folders.append(
|
||||
FolderResponse(
|
||||
|
|
@ -1209,23 +1254,61 @@ async def update_folder(
|
|||
)
|
||||
|
||||
|
||||
async def _restricted_folder_delete_blocker(db: AsyncSession, folder: LibraryFolder) -> str | None:
|
||||
"""Why a library:delete_own user may NOT delete this folder, or None if they may.
|
||||
|
||||
Folders have no ownership tracking, so users without library:delete_all may
|
||||
only delete folders that are truly empty — an empty folder contains nobody's
|
||||
data (#1781). "Empty" must include trashed files: LibraryFile.folder_id
|
||||
cascades on folder delete, so a folder holding another user's trashed file
|
||||
would silently break trash restore.
|
||||
"""
|
||||
if folder.is_external:
|
||||
return "External folders can only be deleted by users with library:delete_all"
|
||||
if folder.project_id is not None or folder.archive_id is not None:
|
||||
return "Folders linked to a project or archive can only be deleted by users with library:delete_all"
|
||||
|
||||
child_result = await db.execute(select(func.count(LibraryFolder.id)).where(LibraryFolder.parent_id == folder.id))
|
||||
if (child_result.scalar() or 0) > 0:
|
||||
return "Only empty folders can be deleted without library:delete_all"
|
||||
|
||||
# Includes trashed files (no deleted_at filter) — see docstring.
|
||||
file_result = await db.execute(select(func.count(LibraryFile.id)).where(LibraryFile.folder_id == folder.id))
|
||||
if (file_result.scalar() or 0) > 0:
|
||||
return "Only empty folders can be deleted without library:delete_all (the folder may contain trashed files)"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_id}")
|
||||
async def delete_folder(
|
||||
folder_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_DELETE_ALL)),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
Permission.LIBRARY_DELETE_ALL,
|
||||
Permission.LIBRARY_DELETE_OWN,
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Delete a folder and all its contents (cascade).
|
||||
|
||||
Note: Folders require library:delete_all permission since they don't have
|
||||
ownership tracking.
|
||||
Folders have no ownership tracking, so cascade deletion requires
|
||||
library:delete_all. Users with only library:delete_own may delete empty,
|
||||
non-external, non-linked folders (#1781).
|
||||
"""
|
||||
_, can_modify_all = auth_result
|
||||
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
folder = result.scalar_one_or_none()
|
||||
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
if not can_modify_all:
|
||||
blocker = await _restricted_folder_delete_blocker(db, folder)
|
||||
if blocker:
|
||||
raise HTTPException(status_code=403, detail=blocker)
|
||||
|
||||
# External folders: only remove DB records, never delete files from external path
|
||||
is_ext = folder.is_external
|
||||
|
||||
|
|
@ -1482,6 +1565,16 @@ async def create_external_folder(
|
|||
)
|
||||
|
||||
|
||||
def _mtime_to_datetime(mtime: float) -> datetime:
|
||||
"""Convert an ``os.stat().st_mtime`` epoch value to a naive-UTC datetime (#2680).
|
||||
|
||||
Naive UTC to match the other library timestamp columns (``created_at`` /
|
||||
``updated_at`` are naive ``func.now()``), so activity comparisons never mix
|
||||
naive and aware values on either dialect.
|
||||
"""
|
||||
return datetime.fromtimestamp(mtime, tz=timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/scan")
|
||||
async def scan_external_folder(
|
||||
folder_id: int,
|
||||
|
|
@ -1557,6 +1650,8 @@ async def scan_external_folder(
|
|||
removed = 0
|
||||
found_paths: set[str] = set()
|
||||
seen_rel_dirs: set[str] = set()
|
||||
# Real on-disk mtime per visited folder id (#2680), applied after the walk.
|
||||
folder_mtimes: dict[int, datetime] = {}
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(ext_path):
|
||||
# Filter hidden directories unless configured
|
||||
|
|
@ -1606,6 +1701,15 @@ async def scan_external_folder(
|
|||
|
||||
target_folder_id = folder_cache.get(rel_dir, folder_id)
|
||||
|
||||
# Record this directory's own mtime (#2680). os.walk visits every
|
||||
# directory once, so this covers the root external folder and every
|
||||
# subfolder (existing or just created). Applied to the folder rows
|
||||
# after the walk completes.
|
||||
try:
|
||||
folder_mtimes[target_folder_id] = _mtime_to_datetime(os.stat(dirpath).st_mtime)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for filename in filenames:
|
||||
# Skip hidden files unless configured
|
||||
if not folder.external_show_hidden and filename.startswith("."):
|
||||
|
|
@ -1634,7 +1738,17 @@ async def scan_external_folder(
|
|||
found_paths.add(file_path_str)
|
||||
|
||||
if file_path_str in existing_files:
|
||||
continue # Already tracked
|
||||
# Already tracked — refresh its on-disk mtime (#2680) so a file
|
||||
# edited/replaced over the mount (samba, etc.) re-sorts correctly
|
||||
# and old rows scanned before this field existed get backfilled.
|
||||
tracked = existing_files[file_path_str]
|
||||
try:
|
||||
fs_mtime = _mtime_to_datetime(filepath.stat().st_mtime)
|
||||
except OSError:
|
||||
fs_mtime = None
|
||||
if fs_mtime is not None and tracked.fs_modified_at != fs_mtime:
|
||||
tracked.fs_modified_at = fs_mtime
|
||||
continue
|
||||
|
||||
# Get file info
|
||||
try:
|
||||
|
|
@ -1717,6 +1831,7 @@ async def scan_external_folder(
|
|||
file_hash=None, # Skip hashing external files for performance
|
||||
thumbnail_path=thumbnail_path,
|
||||
file_metadata=_without_print_name(file_metadata),
|
||||
fs_modified_at=_mtime_to_datetime(stat.st_mtime), # #2680: real on-disk mtime
|
||||
)
|
||||
db.add(db_file)
|
||||
added += 1
|
||||
|
|
@ -1767,6 +1882,16 @@ async def scan_external_folder(
|
|||
sub_folder_obj = sub_folder_result.scalar_one_or_none()
|
||||
if sub_folder_obj:
|
||||
await db.delete(sub_folder_obj)
|
||||
folder_mtimes.pop(sub_fid, None)
|
||||
|
||||
# Persist each visited folder's real directory mtime (#2680). Fetched in one
|
||||
# trip; folders deleted by the cleanup above were dropped from folder_mtimes.
|
||||
if folder_mtimes:
|
||||
folders_result = await db.execute(select(LibraryFolder).where(LibraryFolder.id.in_(list(folder_mtimes.keys()))))
|
||||
for folder_obj in folders_result.scalars().all():
|
||||
new_mtime = folder_mtimes.get(folder_obj.id)
|
||||
if new_mtime is not None and folder_obj.fs_modified_at != new_mtime:
|
||||
folder_obj.fs_modified_at = new_mtime
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
|
@ -1928,6 +2053,7 @@ async def list_files(
|
|||
created_by_id=f.created_by_id,
|
||||
created_by_username=f.created_by.username if f.created_by else None,
|
||||
created_at=f.created_at,
|
||||
fs_modified_at=f.fs_modified_at,
|
||||
print_name=print_name,
|
||||
print_time_seconds=print_time,
|
||||
filament_used_grams=filament_grams,
|
||||
|
|
@ -2542,6 +2668,17 @@ async def add_files_to_queue(
|
|||
result = await db.execute(LibraryFile.active().where(LibraryFile.id.in_(request.file_ids)))
|
||||
files = {f.id: f for f in result.scalars().all()}
|
||||
|
||||
# Project attribution (#1897): a file queued from a project-linked folder
|
||||
# inherits that project, so the resulting archive counts toward the
|
||||
# project's progress. A file's own project link wins over its folder's.
|
||||
folder_ids = {f.folder_id for f in files.values() if f.folder_id is not None}
|
||||
folder_projects: dict[int, int | None] = {}
|
||||
if folder_ids:
|
||||
folder_result = await db.execute(
|
||||
select(LibraryFolder.id, LibraryFolder.project_id).where(LibraryFolder.id.in_(folder_ids))
|
||||
)
|
||||
folder_projects = dict(folder_result.all())
|
||||
|
||||
# Get max position for queue ordering
|
||||
pos_result = await db.execute(select(func.coalesce(func.max(PrintQueueItem.position), 0)))
|
||||
max_position = pos_result.scalar() or 0
|
||||
|
|
@ -2579,6 +2716,8 @@ async def add_files_to_queue(
|
|||
queue_item = PrintQueueItem(
|
||||
printer_id=None, # Unassigned
|
||||
library_file_id=file_id,
|
||||
project_id=lib_file.project_id
|
||||
or (folder_projects.get(lib_file.folder_id) if lib_file.folder_id is not None else None),
|
||||
position=max_position,
|
||||
status="pending",
|
||||
)
|
||||
|
|
@ -2644,11 +2783,23 @@ async def get_library_file_plates(
|
|||
# SliceModal to default its dropdowns (#1325). Initialised here so the
|
||||
# final return never raises NameError when the file isn't a valid zip.
|
||||
embedded_presets: dict[str, str | None] = {"printer": None, "process": None}
|
||||
# Process settings the designer changed away from the stock preset (#2622).
|
||||
# Offered in the SliceModal so a cross-printer re-slice can carry them
|
||||
# instead of silently losing them to the picked process profile.
|
||||
design_overrides: list[dict] = []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
namelist = zf.namelist()
|
||||
embedded_presets = extract_embedded_presets_from_3mf(zf)
|
||||
if _PROJECT_SETTINGS_PATH in namelist:
|
||||
try:
|
||||
design_overrides = [
|
||||
o._asdict()
|
||||
for o in overrides_from_config(json.loads(zf.read(_PROJECT_SETTINGS_PATH).decode("utf-8")))
|
||||
]
|
||||
except (ValueError, OSError, KeyError):
|
||||
design_overrides = []
|
||||
|
||||
# Find all plate gcode files to determine available plates
|
||||
gcode_files = [n for n in namelist if n.startswith("Metadata/plate_") and n.endswith(".gcode")]
|
||||
|
|
@ -2877,6 +3028,7 @@ async def get_library_file_plates(
|
|||
"is_multi_plate": len(plates) > 1,
|
||||
"embedded_printer": embedded_presets["printer"],
|
||||
"embedded_process": embedded_presets["process"],
|
||||
"design_overrides": design_overrides,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2930,6 +3082,7 @@ async def _try_preview_slice_filaments(
|
|||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.services.slice_preview import get_preview_filaments
|
||||
from backend.app.services.slicer_api import get_stall_timeout_seconds
|
||||
|
||||
preferred = (await get_setting(db, "preferred_slicer")) or "bambu_studio"
|
||||
if preferred == "orcaslicer":
|
||||
|
|
@ -2955,6 +3108,7 @@ async def _try_preview_slice_filaments(
|
|||
file_name=file_path.name,
|
||||
api_url=api_url,
|
||||
request_id=request_id,
|
||||
timeout_seconds=await get_stall_timeout_seconds(db),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2963,6 +3117,7 @@ async def get_library_file_filament_requirements(
|
|||
file_id: int,
|
||||
plate_id: int | None = None,
|
||||
request_id: str | None = None,
|
||||
full_slots: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
auth_result: tuple[User | None, bool] = Depends(
|
||||
require_ownership_permission(
|
||||
|
|
@ -2979,6 +3134,10 @@ async def get_library_file_filament_requirements(
|
|||
Args:
|
||||
file_id: The library file ID
|
||||
plate_id: Optional plate index to get filaments for a specific plate
|
||||
full_slots: Return one entry per *project* slot rather than only the
|
||||
slots the plate consumes. See :func:`_expand_to_project_slots`.
|
||||
Only the slice modal wants this; print-time AMS matching must keep
|
||||
the used-only list.
|
||||
"""
|
||||
import defusedxml.ElementTree as ET
|
||||
|
||||
|
|
@ -3081,6 +3240,17 @@ async def get_library_file_filament_requirements(
|
|||
}
|
||||
)
|
||||
|
||||
# Re-slicing a source that already carries slice_info (#2712).
|
||||
# The block above answers "what does this plate consume", which is
|
||||
# what print-time AMS matching needs. The slice modal needs "what
|
||||
# slots exist", because its list is positional and the CLI binds
|
||||
# entry N to slot N — so a source using only slot 4 handed the
|
||||
# user's single pick to slot 1 and sliced slot 4 with the source's
|
||||
# embedded default. Widen here rather than in the modal so the
|
||||
# print path keeps the narrow list it depends on.
|
||||
if full_slots and filaments:
|
||||
filaments = expand_to_project_slots(zf, filaments)
|
||||
|
||||
# Unsliced project files: slice_info had no per-plate data.
|
||||
# Return the FULL project_settings.config AMS slot list so
|
||||
# the slicer CLI receives a profile for every project slot
|
||||
|
|
@ -3439,6 +3609,8 @@ async def _run_slicer_with_fallback(
|
|||
SlicerApiService,
|
||||
SlicerApiUnavailableError,
|
||||
SlicerInputError,
|
||||
SlicerTimeoutError,
|
||||
get_stall_timeout_seconds,
|
||||
)
|
||||
|
||||
user: User | None = None
|
||||
|
|
@ -3527,6 +3699,21 @@ async def _run_slicer_with_fallback(
|
|||
# with a PVA slot loaded but never used.
|
||||
presets["process"] = _patch_process_support_settings(presets["process"], primary_bytes)
|
||||
|
||||
# #2622: carry the designer's own process tweaks onto the picked preset.
|
||||
# BambuStudio records exactly which keys deviate from the system preset
|
||||
# in `different_settings_to_system`, so a MakerWorld author's 5 walls /
|
||||
# 100% infill / 0.1mm first layer survive a re-slice for another printer
|
||||
# instead of being flattened by --load-settings. Opt-in per key: only the
|
||||
# keys the caller names are applied, and only if the source really lists
|
||||
# them as changed. Runs after the #1881 support patch so an explicit
|
||||
# design pick wins over the blanket support carry-over.
|
||||
if request.design_overrides:
|
||||
presets["process"] = apply_design_overrides(
|
||||
presets["process"],
|
||||
extract_design_process_overrides(primary_bytes),
|
||||
request.design_overrides,
|
||||
)
|
||||
|
||||
used_embedded_settings = False
|
||||
# "Slice as designed" (#2611): honour the file's embedded
|
||||
# project_settings.config instead of the picked profile triplet. Only
|
||||
|
|
@ -3534,7 +3721,9 @@ async def _run_slicer_with_fallback(
|
|||
# gates the toggle on the picked printer matching the design's target,
|
||||
# so this path never re-targets across printer models.
|
||||
embedded_mode = bool(request.use_embedded_settings and is_3mf)
|
||||
service = SlicerApiService(api_url)
|
||||
# Bounds silence rather than total slicing time (#2730), so a heavy model
|
||||
# that keeps reporting progress runs to completion however long it takes.
|
||||
service = SlicerApiService(api_url, timeout_seconds=await get_stall_timeout_seconds(db))
|
||||
|
||||
# #1493: cross-nozzle-class re-slice (single <-> dual). Without
|
||||
# intervention the slicer rejects with either "G-code in unprintable
|
||||
|
|
@ -3595,10 +3784,26 @@ async def _run_slicer_with_fallback(
|
|||
# with printer …" (#2628). Replace unused-slot entries with the
|
||||
# plate's lowest used slot before the real slice so the loaded set is
|
||||
# materially homogeneous and printer-correct.
|
||||
if is_3mf and request.plate is not None:
|
||||
#
|
||||
# ``plate`` is absent for single-plate and STL sources — the SliceModal
|
||||
# skips the picker and omits the field — and absent means plate 1, the
|
||||
# same reading as ``plate_num`` further down and as the schema's own
|
||||
# description. Treating it as "unknown plate" instead is what left every
|
||||
# single-plate 3MF unsubstituted (#2711): a MakerWorld project defining
|
||||
# four filaments but painting only one reached the CLI with the other
|
||||
# three still holding presets baked into the source for a different
|
||||
# printer, and the slice died on the first of them.
|
||||
#
|
||||
# ``plate=0`` is the slice-all sentinel, not a plate: every slot is used
|
||||
# by some plate, so there is nothing to substitute. It has to be excluded
|
||||
# explicitly because the support-filament slots unioned in below are
|
||||
# read from the project config and are not plate-scoped — they would
|
||||
# survive the (empty) geometry lookup for plate 0 and become the anchor,
|
||||
# collapsing every colour of a slice-all onto the support filament.
|
||||
if is_3mf and request.plate != 0:
|
||||
from backend.app.services.slicer_3mf_convert import substitute_unused_plate_filaments
|
||||
|
||||
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate, filament_jsons)
|
||||
filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
|
||||
|
||||
# Cross-class slice-all loop (#1493): when the user asks for
|
||||
# ``plate=0`` (all plates) AND the source's nozzle class differs from
|
||||
|
|
@ -3761,6 +3966,12 @@ async def _run_slicer_with_fallback(
|
|||
used_embedded_settings = True
|
||||
except SlicerInputError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except SlicerTimeoutError as exc:
|
||||
# 504, not 502: the sidecar answered for the whole run, we stopped
|
||||
# waiting. Reported separately so the user is told the slice ran out of
|
||||
# time and where to change that, rather than that the sidecar is
|
||||
# unreachable — which is what a read timeout used to look like (#2730).
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except SlicerApiServerError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except SlicerApiUnavailableError as exc:
|
||||
|
|
@ -4810,16 +5021,15 @@ async def bulk_delete(
|
|||
file.deleted_at = now
|
||||
deleted_files += 1
|
||||
|
||||
# Delete folders (cascade will handle contents)
|
||||
# Note: Folders don't have ownership tracking currently, require *_all permission
|
||||
# Delete folders (cascade will handle contents). Folders have no ownership
|
||||
# tracking, so users without *_all permission may only delete empty,
|
||||
# non-external, non-linked folders (#1781) — same rule as DELETE /folders/{id}.
|
||||
for folder_id in data.folder_ids:
|
||||
if not can_modify_all:
|
||||
# Users without *_all permission cannot delete folders
|
||||
continue
|
||||
|
||||
result = await db.execute(select(LibraryFolder).where(LibraryFolder.id == folder_id))
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder:
|
||||
if not can_modify_all and await _restricted_folder_delete_blocker(db, folder):
|
||||
continue
|
||||
# Count files that will be deleted
|
||||
file_count_result = await db.execute(
|
||||
select(func.count(LibraryFile.id)).where(
|
||||
|
|
|
|||
|
|
@ -1404,6 +1404,18 @@ async def create_oidc_provider(
|
|||
return _build_provider_response(provider)
|
||||
|
||||
|
||||
def _refuse_if_env_managed(provider: OIDCProvider) -> None:
|
||||
"""Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
|
||||
edit here would be accepted and then silently reverted at the next restart.
|
||||
BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
|
||||
unusable, so refusing outright cannot lock anyone out."""
|
||||
if provider.is_env_managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This OIDC provider is managed by environment variables and cannot be modified.",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
|
||||
async def update_oidc_provider(
|
||||
provider_id: int,
|
||||
|
|
@ -1426,6 +1438,7 @@ async def update_oidc_provider(
|
|||
provider = result2.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
if body.default_group_id is not None:
|
||||
grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
|
||||
|
|
@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
|
|||
provider = result2.scalar_one_or_none()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
await db.delete(provider)
|
||||
await db.commit()
|
||||
|
|
@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
|
|||
provider = result.scalar_one_or_none()
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
|
||||
# Setting deferred columns is safe — no read happens, just a write.
|
||||
provider.icon_url = None
|
||||
|
|
@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
|
|||
provider = result.scalar_one_or_none()
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
|
||||
_refuse_if_env_managed(provider)
|
||||
if not provider.icon_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
|
|||
"on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
|
||||
# Build plate detection
|
||||
"on_plate_not_empty": provider.on_plate_not_empty,
|
||||
"on_plate_clear_required": provider.on_plate_clear_required,
|
||||
# Bed cooled
|
||||
"on_bed_cooled": provider.on_bed_cooled,
|
||||
# First layer complete
|
||||
|
|
@ -139,6 +140,7 @@ async def create_notification_provider(
|
|||
on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
|
||||
# Build plate detection
|
||||
on_plate_not_empty=provider_data.on_plate_not_empty,
|
||||
on_plate_clear_required=provider_data.on_plate_clear_required,
|
||||
# Bed cooled
|
||||
on_bed_cooled=provider_data.on_bed_cooled,
|
||||
# First layer complete
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ router = APIRouter(prefix="/obico", tags=["obico"])
|
|||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
url: str
|
||||
# Omitted entirely = test with the saved token; "" = test with no token.
|
||||
token: str | None = None
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
|
|
@ -37,15 +39,43 @@ async def get_status(
|
|||
}
|
||||
|
||||
|
||||
@router.get("/printer-status")
|
||||
async def get_printer_status(
|
||||
user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
|
||||
):
|
||||
"""Per-printer live classification for the printer cards (#1546).
|
||||
|
||||
Deliberately excludes configuration (ML URL, action, history) so users
|
||||
with printers:read but no settings:read can still render the badge.
|
||||
"""
|
||||
settings = await obico_detection_service._load_settings()
|
||||
enabled_printers = settings["enabled_printers"]
|
||||
# Error strings can embed configured URLs (ML API base, external URL), so
|
||||
# they stay behind settings:read like the rest of the configuration.
|
||||
can_see_error = user is None or user.has_permission(Permission.SETTINGS_READ.value)
|
||||
return {
|
||||
"enabled": settings["enabled"],
|
||||
# None = all printers are monitored
|
||||
"monitored_printers": sorted(enabled_printers) if enabled_printers is not None else None,
|
||||
"per_printer": obico_detection_service.get_per_printer(),
|
||||
"last_error": obico_detection_service._last_error if can_see_error else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
async def test_connection(
|
||||
req: TestConnectionRequest,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Ping the Obico ML API `/hc/` health endpoint. Returns ok + raw body."""
|
||||
"""Ping the Obico ML API health endpoint and check the token. Returns ok + raw body."""
|
||||
if not req.url:
|
||||
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty"}
|
||||
return await obico_detection_service.test_connection(req.url)
|
||||
return {"ok": False, "status_code": None, "body": None, "error": "URL is empty", "auth_ok": None}
|
||||
token = req.token
|
||||
if token is None:
|
||||
# Field omitted entirely — test what the service actually uses.
|
||||
settings = await obico_detection_service._load_settings()
|
||||
token = settings.get("ml_token") or ""
|
||||
return await obico_detection_service.test_connection(req.url, token)
|
||||
|
||||
|
||||
@router.get("/cached-frame/{nonce}")
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
|
|||
async def _build_authenticated_service(
|
||||
db: AsyncSession,
|
||||
user: User | None,
|
||||
clear_on_auth_failure: bool = True,
|
||||
) -> OrcaCloudService:
|
||||
"""Construct an :class:`OrcaCloudService` pre-populated with stored
|
||||
credentials. If the access token is within the refresh-leeway of expiry,
|
||||
|
|
@ -440,7 +441,24 @@ async def _build_authenticated_service(
|
|||
We don't lock around the refresh: Orca tolerates concurrent refreshes for
|
||||
~60s (each racer gets its own valid pair on the same connection rather than
|
||||
a revoke), so a lost race here is harmless — last-write-wins on the stored
|
||||
pair, and whichever pair we keep is valid."""
|
||||
pair, and whichever pair we keep is valid.
|
||||
|
||||
``clear_on_auth_failure`` controls what happens when the refresh is
|
||||
rejected. Routes leave it on: the caller is a person looking at the UI, and
|
||||
wiping the dead credentials flips the page to disconnected in front of them
|
||||
so they can pair again. Background jobs pass ``False`` — see the caveat
|
||||
below.
|
||||
|
||||
Why background callers must not clear: Orca reports every rejection with
|
||||
one composite reason (``unknown, expired, revoked, or already used``), so
|
||||
a genuine revocation is indistinguishable from a lost refresh-rotation
|
||||
race. Acting destructively on a signal that can't be disambiguated is the
|
||||
#2562 mistake in a different cloud. It also gains nothing — a route call
|
||||
hits the same failure and clears then, at a moment the user can respond to.
|
||||
A successful refresh is still persisted either way: by that point the old
|
||||
refresh token is consumed, so dropping the new pair would break a working
|
||||
pairing for real.
|
||||
"""
|
||||
creds = await _load_credentials(db, user)
|
||||
if not creds.token:
|
||||
raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
|
||||
|
|
@ -457,8 +475,11 @@ async def _build_authenticated_service(
|
|||
await svc.refresh()
|
||||
except OrcaCloudAuthError as e:
|
||||
# Refresh token was revoked or rotated out from under us. Clear
|
||||
# the stale credentials so the UI flips to disconnected.
|
||||
await _clear_credentials(db, user)
|
||||
# the stale credentials so the UI flips to disconnected — unless
|
||||
# the caller is a background job, which must not change sign-in
|
||||
# state on its own.
|
||||
if clear_on_auth_failure:
|
||||
await _clear_credentials(db, user)
|
||||
raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
|
||||
except OrcaCloudError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
|
||||
|
|
|
|||
|
|
@ -250,6 +250,24 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
|
|||
response.nozzle_diameter = item.archive.nozzle_diameter
|
||||
response.sliced_for_model = item.archive.sliced_for_model
|
||||
response.bed_type = item.archive.bed_type
|
||||
# Marks history/reprint rows whose archive carries the slicer's own
|
||||
# live-resolved AMS-slot pick (extra_data.slicer_ams_mapping) — see
|
||||
# `_extract_slicer_ams_mapping_json` in virtual_printer/manager.py.
|
||||
#
|
||||
# Only when the saved mapping was resolved against *this* row's
|
||||
# printer: a global tray ID means nothing on another printer, so
|
||||
# that's the exact condition under which the mapping is reused. A
|
||||
# badge on a row where nothing gets reused would be a lie (#2700
|
||||
# review). Model-based rows (printer_id None) never match, which is
|
||||
# correct — the mapping is not reused there either.
|
||||
extra = item.archive.extra_data if isinstance(item.archive.extra_data, dict) else {}
|
||||
saved_mapping = extra.get("slicer_ams_mapping")
|
||||
response.archive_has_slicer_ams_mapping = (
|
||||
isinstance(saved_mapping, dict)
|
||||
and isinstance(saved_mapping.get("mapping"), list)
|
||||
and item.printer_id is not None
|
||||
and saved_mapping.get("printer_id") == item.printer_id
|
||||
)
|
||||
if item.plate_id:
|
||||
archive_path = settings.base_dir / item.archive.file_path
|
||||
if archive_path.exists():
|
||||
|
|
@ -643,6 +661,51 @@ async def add_to_queue(
|
|||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
|
||||
# Reprint fallback: the caller didn't specify an explicit ams_mapping (no
|
||||
# per-slot filament-mapping edit was made), but the archive carries the
|
||||
# slicer's own live-resolved AMS-slot pick from the original print (see
|
||||
# `extra_data.slicer_ams_mapping`, written by the VP-queue path via
|
||||
# `_extract_slicer_ams_mapping_json`). Reuse it so the reprint dispatches
|
||||
# to the exact same physical spool instead of the scheduler re-deriving a
|
||||
# (possibly ambiguous) mapping from just the file's static type/color.
|
||||
#
|
||||
# Global tray IDs only mean something relative to the specific printer
|
||||
# they were resolved against, so this only fires when the reprint targets
|
||||
# that exact printer (`extra_data.slicer_ams_mapping.printer_id`) — never
|
||||
# for a model-based dispatch (data.printer_id is None) or a reprint aimed
|
||||
# at a different printer, where the same tray number can hold a
|
||||
# completely different spool (#2700 review).
|
||||
#
|
||||
# It also stands down when the request carries force-color-match overrides:
|
||||
# those are the caller asking the scheduler to match strictly against the
|
||||
# printer's live trays, and they are only ever applied inside
|
||||
# `_compute_ams_mapping_for_printer` — the function a stored mapping makes
|
||||
# the scheduler skip. Same precedence as the VP-side toggle pair (#2700
|
||||
# review).
|
||||
#
|
||||
# Note this is otherwise unconditional — it applies regardless of whether
|
||||
# the physical spool in that slot has changed since the original print.
|
||||
# #1308 covers re-verifying a stored mapping against live AMS state at
|
||||
# dispatch time; that check is a separate PR and, once merged, will also
|
||||
# catch a stale slot inherited through this fallback.
|
||||
wants_live_color_match = any(
|
||||
isinstance(o, dict) and o.get("force_color_match") for o in (data.filament_overrides or [])
|
||||
)
|
||||
if (
|
||||
ams_mapping_json is None
|
||||
and not wants_live_color_match
|
||||
and archive
|
||||
and archive.extra_data
|
||||
and data.printer_id is not None
|
||||
):
|
||||
saved = archive.extra_data.get("slicer_ams_mapping")
|
||||
if (
|
||||
isinstance(saved, dict)
|
||||
and saved.get("printer_id") == data.printer_id
|
||||
and isinstance(saved.get("mapping"), list)
|
||||
and saved["mapping"]
|
||||
):
|
||||
ams_mapping_json = json.dumps(saved["mapping"])
|
||||
items = []
|
||||
for i in range(quantity):
|
||||
item = PrintQueueItem(
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ from backend.app.services.printer_manager import (
|
|||
)
|
||||
from backend.app.utils.filament_ids import filament_id_to_setting_id
|
||||
from backend.app.utils.http import build_content_disposition
|
||||
from backend.app.utils.printer_models import uses_exhaust_fan_label
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/printers", tags=["printers"])
|
||||
|
|
@ -798,6 +799,8 @@ async def get_printer_status(
|
|||
big_fan1_speed=state.big_fan1_speed,
|
||||
big_fan2_speed=state.big_fan2_speed,
|
||||
heatbreak_fan_speed=state.heatbreak_fan_speed,
|
||||
left_aux_fan_speed=state.left_aux_fan_speed,
|
||||
exhaust_fan_present=state.exhaust_fan_present,
|
||||
firmware_version=state.firmware_version,
|
||||
developer_mode=state.developer_mode if state else None,
|
||||
ams_filament_backup=state.ams_filament_backup if state else None,
|
||||
|
|
@ -3193,16 +3196,28 @@ async def set_chamber_temperature(
|
|||
@router.post("/{printer_id}/fan-speed")
|
||||
async def set_fan_speed(
|
||||
printer_id: int,
|
||||
fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
|
||||
fan: str = Query(..., description="Fan to control: part, aux, aux2 (left aux), or chamber"),
|
||||
speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
|
||||
_=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Set a fan speed by percentage."""
|
||||
fan_ids = {"part": 1, "aux": 2, "chamber": 3}
|
||||
"""Set a fan speed by percentage.
|
||||
|
||||
Fan index 10 ("aux2") is the optional left auxiliary part cooling fan on
|
||||
P2S/X2D — driven with "M106 P10" exactly like Bambu's official machine
|
||||
profile gcode does. It only exists when the printer reports airduct part 10,
|
||||
so the request is rejected rather than sending M106 P10 into the void on a
|
||||
machine that has no such fan.
|
||||
|
||||
That gate also rejects for the short window between connecting and the
|
||||
first airduct push, when nothing is known about the fan yet. The card hides
|
||||
the badge over the same window, so there is no control to click; a direct
|
||||
API caller gets a 400 and should retry once the status reports the fan.
|
||||
"""
|
||||
fan_ids = {"part": 1, "aux": 2, "chamber": 3, "aux2": 10}
|
||||
fan_id = fan_ids.get(fan)
|
||||
if fan_id is None:
|
||||
raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
|
||||
raise HTTPException(400, "fan must be 'part', 'aux', 'aux2', or 'chamber'")
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
|
|
@ -3213,12 +3228,31 @@ async def set_fan_speed(
|
|||
if not client:
|
||||
raise HTTPException(400, "Printer not connected")
|
||||
|
||||
# Presence gate for the accessory fan. Without this, aux2 is accepted for
|
||||
# every model and an A1 would be sent M106 P10 for a fan it does not have.
|
||||
# The UI already hides the badge; this closes the same hole on the API.
|
||||
if fan == "aux2" and getattr(client.state, "left_aux_fan_speed", None) is None:
|
||||
raise HTTPException(
|
||||
400,
|
||||
"This printer does not report a left auxiliary fan "
|
||||
"(no airduct part 10). The fan is an accessory kit on the P2S "
|
||||
"and factory-fitted on the X2D.",
|
||||
)
|
||||
|
||||
pwm_speed = round(speed * 255 / 100)
|
||||
success = client.set_fan_speed(fan_id, pwm_speed)
|
||||
if not success:
|
||||
raise HTTPException(500, "Failed to set fan speed")
|
||||
|
||||
fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
|
||||
# The enclosure fan is called "Exhaust" on P2S/X2D and "Chamber" elsewhere;
|
||||
# match whatever the printer card badge shows so the toast agrees with the
|
||||
# control the user just clicked.
|
||||
fan_names = {
|
||||
"part": "Part cooling fan",
|
||||
"aux": "Auxiliary fan",
|
||||
"aux2": "Left auxiliary fan",
|
||||
"chamber": "Exhaust fan" if uses_exhaust_fan_label(printer.model) else "Chamber fan",
|
||||
}
|
||||
return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from backend.app.schemas.project import (
|
|||
BOMItemUpdate,
|
||||
ProjectChildPreview,
|
||||
ProjectCreate,
|
||||
ProjectFileProgress,
|
||||
ProjectImport,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
|
|
@ -51,6 +52,21 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||
|
||||
_FAILURE_STATUSES = ("failed", "aborted", "cancelled", "stopped")
|
||||
|
||||
# Soft-deleted archives (#1343) keep their row — and therefore their
|
||||
# ``project_id`` — after their files have been removed from disk, so that global
|
||||
# Quick Stats can still count their filament / time / cost. Nothing in this
|
||||
# module filtered on that, which left deleted prints listed on the project with
|
||||
# thumbnails pointing at files that no longer exist, and no way to unassign them
|
||||
# (the only unassign UI lives on the Archives page, which correctly hides them)
|
||||
# — #2731.
|
||||
#
|
||||
# Every project-scoped query filters them out, counts included: a project that
|
||||
# lists 11 prints must not claim 12. That is a deliberate divergence from the
|
||||
# global Quick Stats behaviour, where the whole point of the soft delete is that
|
||||
# the contribution survives. A project is a piece of work with a definite
|
||||
# membership, not a lifetime total, so a print the user deleted has left it.
|
||||
_LIVE_ARCHIVE = PrintArchive.deleted_at.is_(None)
|
||||
|
||||
|
||||
async def compute_project_stats(
|
||||
db: AsyncSession, project_id: int, target_count: int | None = None, target_parts_count: int | None = None
|
||||
|
|
@ -82,7 +98,7 @@ async def compute_project_stats(
|
|||
func.coalesce(func.sum(PrintLogEntry.energy_cost), 0).label("total_energy_cost"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
)
|
||||
log_stats = log_stats_result.first()
|
||||
total_archives = int(log_stats.total_runs or 0)
|
||||
|
|
@ -103,7 +119,7 @@ async def compute_project_stats(
|
|||
).label("failed_runs"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
)
|
||||
items_split = items_split_result.first()
|
||||
total_items = int(items_split.total_items or 0)
|
||||
|
|
@ -211,7 +227,7 @@ async def list_projects(
|
|||
).label("failed_count"),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project.id)
|
||||
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
)
|
||||
log_quick = log_quick_result.first()
|
||||
archive_count = int(log_quick.archive_count or 0)
|
||||
|
|
@ -236,7 +252,7 @@ async def list_projects(
|
|||
# Get archive previews (up to 6 most recent)
|
||||
archives_result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.project_id == project.id)
|
||||
.where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(6)
|
||||
)
|
||||
|
|
@ -262,6 +278,7 @@ async def list_projects(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
budget=project.budget,
|
||||
tags=project.tags,
|
||||
due_date=project.due_date,
|
||||
|
|
@ -304,6 +321,7 @@ async def create_project(
|
|||
color=data.color,
|
||||
target_count=data.target_count,
|
||||
target_parts_count=data.target_parts_count,
|
||||
target_sets=data.target_sets,
|
||||
notes=data.notes,
|
||||
tags=data.tags,
|
||||
due_date=data.due_date,
|
||||
|
|
@ -326,6 +344,7 @@ async def create_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -361,7 +380,7 @@ async def list_templates(
|
|||
for project in templates:
|
||||
# Get archive count
|
||||
archive_count_result = await db.execute(
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id)
|
||||
select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project.id, _LIVE_ARCHIVE)
|
||||
)
|
||||
archive_count = archive_count_result.scalar() or 0
|
||||
|
||||
|
|
@ -374,6 +393,7 @@ async def list_templates(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
budget=project.budget,
|
||||
tags=project.tags,
|
||||
due_date=project.due_date,
|
||||
|
|
@ -415,6 +435,7 @@ async def create_project_from_template(
|
|||
color=template.color,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
target_sets=template.target_sets,
|
||||
notes=template.notes,
|
||||
tags=template.tags,
|
||||
priority=template.priority,
|
||||
|
|
@ -457,6 +478,7 @@ async def create_project_from_template(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -491,6 +513,7 @@ async def get_child_previews(db: AsyncSession, parent_id: int) -> list[ProjectCh
|
|||
select(func.coalesce(func.sum(PrintArchive.quantity), 0)).where(
|
||||
PrintArchive.project_id == child.id,
|
||||
PrintArchive.status == "completed",
|
||||
_LIVE_ARCHIVE,
|
||||
)
|
||||
)
|
||||
completed_count = completed_result.scalar() or 0
|
||||
|
|
@ -542,6 +565,7 @@ async def get_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -590,6 +614,10 @@ async def update_project(
|
|||
project.target_count = data.target_count
|
||||
if data.target_parts_count is not None:
|
||||
project.target_parts_count = data.target_parts_count
|
||||
# Sent-but-null clears the copies-per-file target (#1897); omitted leaves it
|
||||
# alone (same #2536 semantics as tags/due_date below).
|
||||
if "target_sets" in data.model_fields_set:
|
||||
project.target_sets = data.target_sets
|
||||
if data.notes is not None:
|
||||
project.notes = data.notes
|
||||
# Sent-but-null clears the field; omitted leaves it alone. Guarding on
|
||||
|
|
@ -642,6 +670,7 @@ async def update_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -702,7 +731,7 @@ async def list_project_archives(
|
|||
query = (
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project), selectinload(PrintArchive.created_by))
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
|
@ -740,6 +769,76 @@ async def list_project_queue(
|
|||
return items
|
||||
|
||||
|
||||
@router.get("/{project_id}/file-progress", response_model=list[ProjectFileProgress])
|
||||
async def get_project_file_progress(
|
||||
project_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PROJECTS_READ),
|
||||
):
|
||||
"""Completed-run counts per library file inside a project (#1897).
|
||||
|
||||
Counts completed ``PrintLogEntry`` rows (same source as the aggregate
|
||||
project stats) of archives attributed to this project, and maps each run to
|
||||
one of the project's library files — the files living in folders linked to
|
||||
the project, the same set the project detail page renders.
|
||||
|
||||
A run is attributed to exactly one file, by the strongest available match:
|
||||
1. ``archive.library_file_id`` (stamped at queue dispatch since #1897),
|
||||
2. content hash (covers historical rows),
|
||||
3. filename (covers hash drift, e.g. re-sliced uploads of the same name).
|
||||
Files with no completed runs are omitted — the frontend treats absence as 0.
|
||||
"""
|
||||
result = await db.execute(select(Project.id).where(Project.id == project_id))
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
files_result = await db.execute(
|
||||
select(LibraryFile.id, LibraryFile.file_hash, LibraryFile.filename)
|
||||
.join(LibraryFolder, LibraryFile.folder_id == LibraryFolder.id)
|
||||
.where(LibraryFolder.project_id == project_id, LibraryFile.deleted_at.is_(None))
|
||||
)
|
||||
file_rows = files_result.all()
|
||||
if not file_rows:
|
||||
return []
|
||||
|
||||
# First match wins within each tier, so iteration order (file id) is stable
|
||||
# when duplicates share a hash or filename.
|
||||
by_id = {fid for fid, _, _ in file_rows}
|
||||
by_hash: dict[str, int] = {}
|
||||
by_name: dict[str, int] = {}
|
||||
for fid, fhash, fname in file_rows:
|
||||
if fhash and fhash not in by_hash:
|
||||
by_hash[fhash] = fid
|
||||
if fname not in by_name:
|
||||
by_name[fname] = fid
|
||||
|
||||
runs_result = await db.execute(
|
||||
select(
|
||||
PrintArchive.library_file_id,
|
||||
PrintArchive.content_hash,
|
||||
PrintArchive.filename,
|
||||
func.count(PrintLogEntry.id),
|
||||
)
|
||||
.join(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
|
||||
.where(PrintArchive.project_id == project_id, PrintLogEntry.status == "completed", _LIVE_ARCHIVE)
|
||||
.group_by(PrintArchive.library_file_id, PrintArchive.content_hash, PrintArchive.filename)
|
||||
)
|
||||
|
||||
counts: dict[int, int] = {}
|
||||
for lib_file_id, content_hash, filename, run_count in runs_result.all():
|
||||
if lib_file_id in by_id:
|
||||
fid = lib_file_id
|
||||
elif content_hash and content_hash in by_hash:
|
||||
fid = by_hash[content_hash]
|
||||
elif filename in by_name:
|
||||
fid = by_name[filename]
|
||||
else:
|
||||
continue
|
||||
counts[fid] = counts.get(fid, 0) + run_count
|
||||
|
||||
return [ProjectFileProgress(file_id=fid, completed_count=n) for fid, n in sorted(counts.items())]
|
||||
|
||||
|
||||
@router.post("/{project_id}/add-archives")
|
||||
async def add_archives_to_project(
|
||||
project_id: int,
|
||||
|
|
@ -1402,6 +1501,7 @@ async def create_template_from_project(
|
|||
color=source.color,
|
||||
target_count=source.target_count,
|
||||
target_parts_count=source.target_parts_count,
|
||||
target_sets=source.target_sets,
|
||||
notes=source.notes,
|
||||
tags=source.tags,
|
||||
priority=source.priority,
|
||||
|
|
@ -1444,6 +1544,7 @@ async def create_template_from_project(
|
|||
status=template.status,
|
||||
target_count=template.target_count,
|
||||
target_parts_count=template.target_parts_count,
|
||||
target_sets=template.target_sets,
|
||||
notes=template.notes,
|
||||
attachments=template.attachments,
|
||||
url=template.url,
|
||||
|
|
@ -1495,7 +1596,7 @@ async def get_project_timeline(
|
|||
# Get archives and add events
|
||||
archives_result = await db.execute(
|
||||
select(PrintArchive)
|
||||
.where(PrintArchive.project_id == project_id)
|
||||
.where(PrintArchive.project_id == project_id, _LIVE_ARCHIVE)
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
|
@ -1653,6 +1754,7 @@ async def export_project(
|
|||
"status": project.status,
|
||||
"target_count": project.target_count,
|
||||
"target_parts_count": project.target_parts_count,
|
||||
"target_sets": project.target_sets,
|
||||
"notes": project.notes,
|
||||
"tags": project.tags,
|
||||
"due_date": project.due_date.isoformat() if project.due_date else None,
|
||||
|
|
@ -1704,6 +1806,7 @@ async def import_project(
|
|||
status=data.status,
|
||||
target_count=data.target_count,
|
||||
target_parts_count=data.target_parts_count,
|
||||
target_sets=data.target_sets,
|
||||
notes=data.notes,
|
||||
tags=data.tags,
|
||||
due_date=data.due_date,
|
||||
|
|
@ -1766,6 +1869,7 @@ async def import_project(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
@ -1829,6 +1933,7 @@ async def import_project_file(
|
|||
status=data.get("status", "active"),
|
||||
target_count=data.get("target_count"),
|
||||
target_parts_count=data.get("target_parts_count"),
|
||||
target_sets=data.get("target_sets"),
|
||||
notes=data.get("notes"),
|
||||
tags=data.get("tags"),
|
||||
due_date=datetime.fromisoformat(data["due_date"]) if data.get("due_date") else None,
|
||||
|
|
@ -1957,6 +2062,7 @@ async def import_project_file(
|
|||
status=project.status,
|
||||
target_count=project.target_count,
|
||||
target_parts_count=project.target_parts_count,
|
||||
target_sets=project.target_sets,
|
||||
notes=project.notes,
|
||||
attachments=project.attachments,
|
||||
url=project.url,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,88 @@ async def get_setting(db: AsyncSession, key: str) -> str | None:
|
|||
return setting.value if setting else None
|
||||
|
||||
|
||||
# Accepted spellings for a boolean settings value. Settings live in a VARCHAR
|
||||
# column and every reader compares them as strings, so these are normalised to
|
||||
# "true"/"false" on the way in. The sets are deliberately generous: these
|
||||
# endpoints are part of the documented REST surface, reached by scripts and by
|
||||
# Home Assistant rest_command, where "True", "1" and "on" are all natural.
|
||||
_TRUTHY_SETTING_VALUES = frozenset({"true", "1", "yes", "on"})
|
||||
_FALSY_SETTING_VALUES = frozenset({"false", "0", "no", "off"})
|
||||
|
||||
|
||||
def setting_is_true(value: object) -> bool:
|
||||
"""Return True if a *stored* settings value means "on".
|
||||
|
||||
Deliberately narrower than the spellings ``normalize_bool_setting`` accepts:
|
||||
it matches only what every other reader in the codebase treats as on
|
||||
(``value.lower() == "true"``). Submitted values are canonicalised on write,
|
||||
so a stored value is always "true"/"false"/""; accepting "1" or "on" here
|
||||
would make this function disagree with the rest of the app about any legacy
|
||||
row containing them.
|
||||
|
||||
A bool is tolerated for the case of a row written before values were
|
||||
normalised, where SQLite coerced a raw bool into the VARCHAR column.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() == "true"
|
||||
|
||||
|
||||
def normalize_bool_setting(key: str, value: object) -> str:
|
||||
"""Coerce a boolean-ish settings value to the canonical "true"/"false".
|
||||
|
||||
Raises HTTPException(400) for values with no sensible interpretation, so an
|
||||
API client gets a message naming the field instead of a 500.
|
||||
|
||||
A JSON boolean is the natural thing for an API client to send, and before
|
||||
this normalisation it caused two distinct failures on
|
||||
``PUT /settings/spoolman``: ``bool.lower()`` raised AttributeError, and the
|
||||
raw bool was written into a VARCHAR column, which SQLite silently coerces
|
||||
to 1/0 while asyncpg rejects outright. Both surfaced as an opaque 500.
|
||||
"""
|
||||
if isinstance(value, bool): # must precede the int branch — bool is an int
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int):
|
||||
if value in (0, 1):
|
||||
return "true" if value else "false"
|
||||
raise HTTPException(400, f"{key} must be a boolean; got the number {value}")
|
||||
if isinstance(value, str):
|
||||
candidate = value.strip().lower()
|
||||
if not candidate:
|
||||
# Empty is stored verbatim rather than normalised to "false".
|
||||
# get_spoolman_settings reads these with ``or "<default>"``, so an
|
||||
# empty stored value means "use the default" — and two of them
|
||||
# (spoolman_report_partial_usage, auto_add_unknown_rfid) default to
|
||||
# ON. Rewriting "" to "false" would silently switch them off for any
|
||||
# client that submits a blank value.
|
||||
return ""
|
||||
if candidate in _TRUTHY_SETTING_VALUES:
|
||||
return "true"
|
||||
if candidate in _FALSY_SETTING_VALUES:
|
||||
return "false"
|
||||
raise HTTPException(400, f"{key} must be a boolean; got {value!r}")
|
||||
raise HTTPException(400, f"{key} must be a boolean; got {type(value).__name__}")
|
||||
|
||||
|
||||
def normalize_str_setting(key: str, value: object) -> str:
|
||||
"""Return a string settings value, rejecting types that would store garbage.
|
||||
|
||||
``str()`` on a dict or list would persist its repr, so those are refused
|
||||
rather than silently written. Numbers are accepted and stringified: a port
|
||||
or a bare host submitted unquoted is a plausible client mistake, not a
|
||||
reason to fail the request.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bool | int | float):
|
||||
return str(value)
|
||||
raise HTTPException(400, f"{key} must be a string; got {type(value).__name__}")
|
||||
|
||||
|
||||
async def get_external_login_url(db: AsyncSession) -> str:
|
||||
"""Get the external URL for the login page.
|
||||
|
||||
|
|
@ -82,6 +164,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
|
|||
"auto_archive",
|
||||
"save_thumbnails",
|
||||
"capture_finish_photo",
|
||||
"finish_photo_restore_plate",
|
||||
"spoolman_enabled",
|
||||
"spoolman_disable_weight_sync",
|
||||
"spoolman_report_partial_usage",
|
||||
|
|
@ -435,14 +518,20 @@ async def update_spoolman_settings(
|
|||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
"""Update Spoolman integration settings."""
|
||||
"""Update Spoolman integration settings.
|
||||
|
||||
The body is a free-form dict rather than a schema, so each value is
|
||||
normalised before it is persisted — see ``normalize_bool_setting`` for why
|
||||
a JSON boolean used to produce a 500 here.
|
||||
"""
|
||||
if "spoolman_enabled" in settings:
|
||||
old_val = await get_setting(db, "spoolman_enabled") or "false"
|
||||
new_val = settings["spoolman_enabled"]
|
||||
was_enabled = setting_is_true(await get_setting(db, "spoolman_enabled"))
|
||||
new_val = normalize_bool_setting("spoolman_enabled", settings["spoolman_enabled"])
|
||||
now_enabled = new_val == "true"
|
||||
await set_setting(db, "spoolman_enabled", new_val)
|
||||
|
||||
# Switching to Spoolman: clear built-in inventory slot assignments
|
||||
if old_val.lower() != "true" and new_val.lower() == "true":
|
||||
if not was_enabled and now_enabled:
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
|
||||
result = await db.execute(delete(SpoolAssignment))
|
||||
|
|
@ -452,21 +541,20 @@ async def update_spoolman_settings(
|
|||
# spoolman_slot_assignments rows linger and would wrongly count as
|
||||
# "assigned" in any mode-agnostic check (e.g. the missing-spool-
|
||||
# assignment notification, which unions both tables — #1473).
|
||||
elif old_val.lower() == "true" and new_val.lower() != "true":
|
||||
elif was_enabled and not now_enabled:
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
|
||||
result = await db.execute(delete(SpoolmanSlotAssignment))
|
||||
logger.info("Cleared %d Spoolman slot assignments on switch to internal mode", result.rowcount)
|
||||
if "spoolman_url" in settings:
|
||||
await set_setting(db, "spoolman_url", settings["spoolman_url"])
|
||||
await set_setting(db, "spoolman_url", normalize_str_setting("spoolman_url", settings["spoolman_url"]))
|
||||
if "spoolman_sync_mode" in settings:
|
||||
await set_setting(db, "spoolman_sync_mode", settings["spoolman_sync_mode"])
|
||||
if "spoolman_disable_weight_sync" in settings:
|
||||
await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
|
||||
if "spoolman_report_partial_usage" in settings:
|
||||
await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
|
||||
if "auto_add_unknown_rfid" in settings:
|
||||
await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
|
||||
await set_setting(
|
||||
db, "spoolman_sync_mode", normalize_str_setting("spoolman_sync_mode", settings["spoolman_sync_mode"])
|
||||
)
|
||||
for bool_key in ("spoolman_disable_weight_sync", "spoolman_report_partial_usage", "auto_add_unknown_rfid"):
|
||||
if bool_key in settings:
|
||||
await set_setting(db, bool_key, normalize_bool_setting(bool_key, settings[bool_key]))
|
||||
|
||||
spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import logging
|
|||
import os
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -300,6 +301,115 @@ def _get_container_memory_limit() -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
# Above this RSS the heap census is skipped — see _collect_process_info.
|
||||
_GC_CENSUS_RSS_LIMIT = 2 * 1024**3
|
||||
|
||||
|
||||
def _collect_process_info() -> dict:
|
||||
"""Snapshot this process's resource usage, for reports about it growing.
|
||||
|
||||
Bundles used to carry nothing about Bambuddy's own footprint, which made
|
||||
"memory climbs over days until the OOM killer fires" impossible to triage
|
||||
from a bundle alone — the reporter of #2734 had to be asked to run commands
|
||||
by hand, and the numbers that would have identified the mechanism could not
|
||||
be recovered after the fact.
|
||||
|
||||
The four figures below separate the mechanisms that look identical from
|
||||
outside:
|
||||
|
||||
* ``rss_bytes`` vs ``vms_bytes`` — a large virtual size against a modest
|
||||
resident one is address space, not live data: thread stacks or allocator
|
||||
arenas rather than a heap that keeps growing.
|
||||
* ``num_threads`` — every leaked MQTT client reconnect would leave a paho
|
||||
network thread behind, each reserving its stack.
|
||||
* ``children`` — the ffmpeg-per-camera-stream leak class (#776).
|
||||
* ``open_files`` / ``connections`` — descriptors held by streams or sockets
|
||||
that were never closed.
|
||||
|
||||
Everything is best-effort: psutil raises on hardened kernels and inside
|
||||
restricted containers, and a support bundle must still be produced when it
|
||||
does. Child command lines are reduced to the executable name — a full
|
||||
ffmpeg argv carries the camera URL, and with it the camera's password.
|
||||
"""
|
||||
import psutil
|
||||
|
||||
out: dict = {}
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
except Exception:
|
||||
return {"available": False}
|
||||
|
||||
out["available"] = True
|
||||
try:
|
||||
mem = proc.memory_info()
|
||||
out["rss_bytes"] = mem.rss
|
||||
out["rss_formatted"] = _format_bytes(mem.rss)
|
||||
out["vms_bytes"] = mem.vms
|
||||
out["vms_formatted"] = _format_bytes(mem.vms)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["num_threads"] = proc.num_threads()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["uptime_seconds"] = int(time.time() - proc.create_time())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["open_files"] = len(proc.open_files())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out["connections"] = len(proc.net_connections(kind="inet"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Children by executable name only. The count per name is what identifies a
|
||||
# leak; the arguments would leak credentials.
|
||||
try:
|
||||
names: dict[str, int] = {}
|
||||
for child in proc.children(recursive=True):
|
||||
try:
|
||||
names[child.name()] = names.get(child.name(), 0) + 1
|
||||
except Exception:
|
||||
names["<unknown>"] = names.get("<unknown>", 0) + 1
|
||||
out["children_total"] = sum(names.values())
|
||||
out["children_by_name"] = dict(sorted(names.items(), key=lambda kv: -kv[1]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Live object counts by type, top 15. Identifies a heap that is growing and
|
||||
# what it is growing with — the one thing RSS alone cannot say.
|
||||
#
|
||||
# Skipped above _GC_CENSUS_RSS_LIMIT. gc.get_objects() materialises a list
|
||||
# of every tracked object, so the census costs most on exactly the process
|
||||
# that can least afford it: a bundle generated to diagnose runaway memory
|
||||
# must not be the allocation that tips the host over. The numbers that
|
||||
# actually separate the mechanisms — RSS vs VMS, threads, children — are
|
||||
# collected above and unaffected.
|
||||
rss = out.get("rss_bytes")
|
||||
if rss is not None and rss > _GC_CENSUS_RSS_LIMIT:
|
||||
out["gc_census"] = (
|
||||
f"skipped: process is using {_format_bytes(rss)}, above the "
|
||||
f"{_format_bytes(_GC_CENSUS_RSS_LIMIT)} limit for walking the heap"
|
||||
)
|
||||
return out
|
||||
try:
|
||||
import gc
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for obj in gc.get_objects():
|
||||
name = type(obj).__name__
|
||||
counts[name] = counts.get(name, 0) + 1
|
||||
out["gc_tracked_objects"] = sum(counts.values())
|
||||
out["gc_top_types"] = dict(sorted(counts.items(), key=lambda kv: -kv[1])[:15])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _format_bytes(size_bytes: int) -> str:
|
||||
"""Format bytes into human-readable string."""
|
||||
if size_bytes < 1024:
|
||||
|
|
@ -647,20 +757,29 @@ async def _collect_slicer_api_info() -> dict:
|
|||
return info
|
||||
|
||||
|
||||
def _parse_obico_enabled_printers(raw: str) -> set[int]:
|
||||
"""Parse the comma-separated `obico_enabled_printers` setting. Same shape as
|
||||
obico_detection.py uses but tolerant of legacy formats."""
|
||||
def _parse_obico_enabled_printers(raw: str | None) -> set[int] | None:
|
||||
"""Parse the `obico_enabled_printers` setting the way the detection service does.
|
||||
|
||||
The setting is a JSON array of printer IDs and an empty value means *all*
|
||||
printers — see ``ObicoDetectionService._load_settings``. This used to split
|
||||
on commas and treat empty as *none*, so a bundle from a default Obico setup
|
||||
reported every printer as unmonitored while the service was in fact polling
|
||||
all of them. Returns ``None`` for "all printers"; a comma-separated fallback
|
||||
is kept in case an install ever stored the legacy shape.
|
||||
"""
|
||||
if not raw or not raw.strip():
|
||||
return set()
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return {int(item) for item in parsed if isinstance(item, (int, str)) and str(item).strip().isdigit()}
|
||||
result: set[int] = set()
|
||||
for token in raw.split(","):
|
||||
token = token.strip()
|
||||
if not token:
|
||||
continue
|
||||
try:
|
||||
if token.isdigit():
|
||||
result.add(int(token))
|
||||
except ValueError:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -690,6 +809,12 @@ async def _collect_support_info() -> dict:
|
|||
"database": {},
|
||||
"printers": [],
|
||||
"settings": {},
|
||||
# Bambuddy's own footprint. Cheap to collect and the only thing that
|
||||
# makes a "memory grows over days" report triageable from the bundle
|
||||
# rather than a round trip of shell commands (#2734). Off the event
|
||||
# loop: the heap census walks every tracked object, and a bundle
|
||||
# request must not stall status ingest while it does.
|
||||
"process": await asyncio.to_thread(_collect_process_info),
|
||||
}
|
||||
|
||||
# Docker-specific info
|
||||
|
|
@ -729,18 +854,27 @@ async def _collect_support_info() -> dict:
|
|||
printers = result.scalars().all()
|
||||
statuses = printer_manager.get_all_statuses()
|
||||
|
||||
# Pre-load the obico per-printer enabled-list. Settings are loaded later
|
||||
# in this function (and would overwrite this key in info["settings"]),
|
||||
# so do a targeted query here for the per-printer flag below.
|
||||
obico_enabled_set: set[int] = set()
|
||||
# Pre-load the obico settings that decide which printers are monitored.
|
||||
# Settings are loaded later in this function (and would overwrite these
|
||||
# keys in info["settings"]), so do a targeted query here for the
|
||||
# per-printer flag below. ``None`` means every printer is monitored.
|
||||
obico_enabled_set: set[int] | None = None
|
||||
obico_globally_enabled = False
|
||||
try:
|
||||
obico_row = (
|
||||
await db.execute(select(Settings).where(Settings.key == "obico_enabled_printers"))
|
||||
).scalar_one_or_none()
|
||||
if obico_row is not None:
|
||||
obico_enabled_set = _parse_obico_enabled_printers(obico_row.value)
|
||||
obico_rows = {
|
||||
row.key: row.value
|
||||
for row in (
|
||||
await db.execute(
|
||||
select(Settings).where(Settings.key.in_(["obico_enabled_printers", "obico_enabled"]))
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
}
|
||||
obico_enabled_set = _parse_obico_enabled_printers(obico_rows.get("obico_enabled_printers"))
|
||||
obico_globally_enabled = (obico_rows.get("obico_enabled") or "false").lower() == "true"
|
||||
except Exception:
|
||||
logger.debug("Failed to load obico_enabled_printers", exc_info=True)
|
||||
logger.debug("Failed to load obico settings", exc_info=True)
|
||||
|
||||
# Check reachability in parallel
|
||||
reachability_tasks = [_check_port(p.ip_address, 8883) for p in printers]
|
||||
|
|
@ -784,7 +918,8 @@ async def _collect_support_info() -> dict:
|
|||
"has_vt_tray": has_vt_tray,
|
||||
"external_camera_configured": bool(printer.external_camera_url),
|
||||
"plate_detection_enabled": printer.plate_detection_enabled,
|
||||
"obico_enabled": printer.id in obico_enabled_set,
|
||||
"obico_enabled": obico_globally_enabled
|
||||
and (obico_enabled_set is None or printer.id in obico_enabled_set),
|
||||
"hms_error_count": len(state.hms_errors) if state else 0,
|
||||
"developer_mode": state.developer_mode if state else None,
|
||||
"nozzle_rack_count": len(state.nozzle_rack) if state else 0,
|
||||
|
|
@ -1227,6 +1362,35 @@ def _redact_raw_push_status(raw: dict) -> dict:
|
|||
return out
|
||||
|
||||
|
||||
def _sanitize_push_status_values(node, sensitive_strings: dict[str, str]):
|
||||
"""Sanitize a push_status snapshot's string *values*, never its JSON text.
|
||||
|
||||
This used to run :func:`sanitize_log_content` over the serialised snapshot.
|
||||
That pass includes a generic Bambu-serial regex
|
||||
(``0[0-3][A-Z0-9][A-Z0-9]{9,13}`` in ``log_reader``) which matches the
|
||||
decimal expansion of a float just as happily as a serial: an AMS ``k`` flow
|
||||
factor of ``0.0199999995529652`` came out as ``0.[SERIAL]``, and the bundle
|
||||
shipped invalid JSON — unusable for exactly the ground-truth purpose the
|
||||
snapshot exists for (found while diagnosing #2702).
|
||||
|
||||
Walking the structure instead leaves numbers, bools and None untouched, so
|
||||
the output always parses. Keys are structural and never rewritten.
|
||||
"""
|
||||
if isinstance(node, str):
|
||||
return sanitize_log_content(node, sensitive_strings)
|
||||
if isinstance(node, dict):
|
||||
return {k: _sanitize_push_status_values(v, sensitive_strings) for k, v in node.items()}
|
||||
if isinstance(node, list | tuple):
|
||||
# Tuples too: `json.dumps` renders them as arrays, so stringifying one
|
||||
# here would change the file's shape rather than just its content.
|
||||
return [_sanitize_push_status_values(v, sensitive_strings) for v in node]
|
||||
if node is None or isinstance(node, bool | int | float):
|
||||
return node
|
||||
# Anything else (datetime, Decimal, …) would be stringified by json.dumps'
|
||||
# ``default=str`` *after* this pass and so escape sanitisation entirely.
|
||||
return sanitize_log_content(str(node), sensitive_strings)
|
||||
|
||||
|
||||
async def _get_recent_sanitized_logs(max_lines: int = 200) -> str:
|
||||
"""Get recent log lines, sanitized for inclusion in bug reports."""
|
||||
# Collect sensitive strings from DB for redaction
|
||||
|
|
@ -1300,12 +1464,13 @@ async def generate_support_bundle(
|
|||
"captured_at": datetime.now(timezone.utc).isoformat(),
|
||||
"raw_data": redacted,
|
||||
}
|
||||
# Belt-and-suspenders: pass the JSON text through the string-based
|
||||
# sanitizer so any user-named string (printer name, serial baked
|
||||
# into a tray uuid) the structural pass missed still gets caught.
|
||||
snapshot_json = json.dumps(snapshot, indent=2, default=str)
|
||||
snapshot_json = sanitize_log_content(snapshot_json, sensitive_strings)
|
||||
zf.writestr(f"push-status/printer-{i + 1}.json", snapshot_json)
|
||||
# Belt-and-suspenders: pass every string value through the
|
||||
# string-based sanitizer so any user-named string (printer name,
|
||||
# serial baked into a tray uuid) the structural pass missed still
|
||||
# gets caught. Values only — sanitizing the serialised JSON text
|
||||
# corrupted numeric literals (see _sanitize_push_status_values).
|
||||
snapshot = _sanitize_push_status_values(snapshot, sensitive_strings)
|
||||
zf.writestr(f"push-status/printer-{i + 1}.json", json.dumps(snapshot, indent=2, default=str))
|
||||
|
||||
# Add log file
|
||||
# Off the event loop: this reads up to 10 MB and then runs one full regex
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
|
|||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool = True
|
||||
queue_force_color_match: bool = False
|
||||
save_ams_mapping: bool = False
|
||||
gcode_injection: bool = False
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
|
@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
|
|||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool | None = None
|
||||
queue_force_color_match: bool | None = None
|
||||
save_ams_mapping: bool | None = None
|
||||
gcode_injection: bool | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
|
@ -109,6 +111,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
|||
"target_printer_id": vp.target_printer_id,
|
||||
"auto_dispatch": vp.auto_dispatch,
|
||||
"queue_force_color_match": vp.queue_force_color_match,
|
||||
"save_ams_mapping": vp.save_ams_mapping,
|
||||
"gcode_injection": vp.gcode_injection,
|
||||
"bind_ip": vp.bind_ip,
|
||||
"remote_interface_ip": vp.remote_interface_ip,
|
||||
|
|
@ -245,6 +248,7 @@ async def create_virtual_printer(
|
|||
target_printer_id=body.target_printer_id,
|
||||
auto_dispatch=body.auto_dispatch,
|
||||
queue_force_color_match=body.queue_force_color_match,
|
||||
save_ams_mapping=body.save_ams_mapping,
|
||||
gcode_injection=body.gcode_injection,
|
||||
bind_ip=body.bind_ip,
|
||||
remote_interface_ip=body.remote_interface_ip,
|
||||
|
|
@ -423,6 +427,8 @@ async def update_virtual_printer(
|
|||
vp.auto_dispatch = body.auto_dispatch
|
||||
if body.queue_force_color_match is not None:
|
||||
vp.queue_force_color_match = body.queue_force_color_match
|
||||
if body.save_ams_mapping is not None:
|
||||
vp.save_ams_mapping = body.save_ams_mapping
|
||||
if body.gcode_injection is not None:
|
||||
vp.gcode_injection = body.gcode_injection
|
||||
if body.bind_ip is not None:
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pydantic import Field
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Application version - single source of truth
|
||||
APP_VERSION = "1.2.5"
|
||||
APP_VERSION = "1.2.5.2"
|
||||
GITHUB_REPO = "maziggy/bambuddy"
|
||||
BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
|
||||
|
||||
|
|
@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
|
|||
"LOG_DIR", # config.py (above)
|
||||
"LOG_LEVEL", # main.py logging setup
|
||||
"BUG_REPORT_RELAY_URL", # config.py (above)
|
||||
# #1589 — api/routes/auth.py reads this on the login path. Unregistered it
|
||||
# logged "possible typo" at every boot, telling an operator who is locked
|
||||
# out and following the documented recovery that the variable is not real.
|
||||
"BAMBUDDY_LOCAL_LOGIN",
|
||||
# #2593 — core/oidc_env.py reads these directly; they are not Settings
|
||||
# fields because they map to an OIDCProvider row, not to app config.
|
||||
"BAMBUDDY_OIDC_NAME",
|
||||
"BAMBUDDY_OIDC_ISSUER_URL",
|
||||
"BAMBUDDY_OIDC_CLIENT_ID",
|
||||
"BAMBUDDY_OIDC_CLIENT_SECRET",
|
||||
"BAMBUDDY_OIDC_SCOPES",
|
||||
"BAMBUDDY_OIDC_ENABLED",
|
||||
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
|
||||
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
|
||||
"BAMBUDDY_OIDC_EMAIL_CLAIM",
|
||||
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
|
||||
"BAMBUDDY_OIDC_ICON_URL",
|
||||
"BAMBUDDY_OIDC_AUTOLOGIN",
|
||||
"BAMBUDDY_OIDC_DEFAULT_GROUP",
|
||||
}
|
||||
|
||||
_known_settings_fields = {f.upper() for f in settings.model_fields}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@ def _set_sqlite_pragmas(dbapi_conn, connection_record):
|
|||
# /system/db-pool can report it without re-deriving the dialect defaults.
|
||||
_pool_config: dict = {}
|
||||
|
||||
# What the PostgreSQL server itself will allow, read once at startup. None on
|
||||
# SQLite, or when the probe could not run. Reported by get_pool_status() so a
|
||||
# support bundle carries both sides of the comparison.
|
||||
_server_connection_limits: dict | None = None
|
||||
|
||||
|
||||
def _resolve_pool_kwargs() -> dict:
|
||||
"""Build the pool kwargs for ``create_async_engine`` (issue #2572).
|
||||
|
|
@ -151,6 +156,10 @@ def get_pool_status() -> dict:
|
|||
return {
|
||||
"dialect": "sqlite" if is_sqlite() else "postgresql",
|
||||
"config": dict(_pool_config),
|
||||
# Both sides of the ceiling-vs-server comparison, so a support bundle
|
||||
# shows whether a TooManyConnectionsError was a misconfiguration or a
|
||||
# genuine leak. None on SQLite or if the startup probe couldn't run.
|
||||
"server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
|
||||
**gauges,
|
||||
}
|
||||
|
||||
|
|
@ -317,6 +326,107 @@ async def init_db():
|
|||
await seed_spool_catalog()
|
||||
await seed_color_catalog()
|
||||
|
||||
await check_pool_fits_server()
|
||||
|
||||
|
||||
async def check_pool_fits_server() -> None:
|
||||
"""Warn when the pool may ask PostgreSQL for more connections than it allows.
|
||||
|
||||
``pool_size + max_overflow`` is the most connections one worker process will
|
||||
ever open. If that exceeds what the server permits, the pool never reaches
|
||||
its own limit and so never queues: it goes straight to the server, which
|
||||
refuses with ``TooManyConnectionsError``. That surfaces wherever the next
|
||||
connection happened to be needed — in the reported case, halfway through a
|
||||
queue dispatch, which then left an expected-print registration and a dispatch
|
||||
claim behind (#2702 follow-up).
|
||||
|
||||
The distinction is worth knowing when reading a log: SQLAlchemy's own
|
||||
``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
|
||||
concurrency, or connections held too long), whereas asyncpg's
|
||||
``TooManyConnectionsError`` means the pool's ceiling is above the server's.
|
||||
|
||||
Not clamped, deliberately. Pool sizes are fixed when the engine is created,
|
||||
which happens at import — before any connection exists to ask the server
|
||||
with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
|
||||
so swapping the engine afterwards would leave stale references. The correct
|
||||
ceiling also depends on the worker count and on anything else sharing the
|
||||
server, neither of which Bambuddy can see. So this reports the mismatch with
|
||||
both numbers and the knobs to fix it, and leaves the choice to the operator.
|
||||
"""
|
||||
global _server_connection_limits
|
||||
if is_sqlite():
|
||||
return
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
in_use: int | None = None
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
|
||||
reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
|
||||
try:
|
||||
in_use = int(
|
||||
(
|
||||
await conn.execute(
|
||||
text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
except Exception as exc:
|
||||
# `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
|
||||
# restricted role sees fewer rows. The count is a nice-to-have
|
||||
# for spotting other clients; the warning itself only needs the
|
||||
# two settings above, so losing it must not cost the warning.
|
||||
# Done last on purpose: a failed statement can abort the
|
||||
# transaction, and nothing else uses this connection after it.
|
||||
logger.debug("Could not count client backends: %s", exc)
|
||||
except Exception as exc:
|
||||
# A diagnostic must never be the reason startup fails. An older server
|
||||
# or a restricted role may refuse these.
|
||||
logger.debug("Could not read PostgreSQL connection limits: %s", exc)
|
||||
return
|
||||
|
||||
available = max_conn - reserved
|
||||
ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
|
||||
_server_connection_limits = {
|
||||
"max_connections": max_conn,
|
||||
"superuser_reserved_connections": reserved,
|
||||
"available_to_bambuddy": available,
|
||||
"client_backends_at_startup": in_use,
|
||||
"pool_ceiling_per_worker": ceiling,
|
||||
}
|
||||
|
||||
if ceiling > available:
|
||||
in_use_note = (
|
||||
f" {in_use} client connection(s) are open on the server right now, including "
|
||||
"this one — a count well above 1 means something else shares it."
|
||||
if in_use is not None
|
||||
else ""
|
||||
)
|
||||
logger.warning(
|
||||
"DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
|
||||
"connections (pool_size %d + max_overflow %d) but the server permits %d "
|
||||
"(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
|
||||
"as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
|
||||
"Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
|
||||
"max_connections — and account for every worker process and any other "
|
||||
"client sharing this server.",
|
||||
ceiling,
|
||||
_pool_config.get("pool_size", 0),
|
||||
_pool_config.get("max_overflow", 0),
|
||||
available,
|
||||
max_conn,
|
||||
reserved,
|
||||
in_use_note,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
|
||||
ceiling,
|
||||
available,
|
||||
max_conn,
|
||||
)
|
||||
|
||||
|
||||
# B2: Module-level counter exposing the number of rows skipped during the last
|
||||
# _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
|
||||
|
|
@ -1253,6 +1363,15 @@ async def run_migrations(conn):
|
|||
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
|
||||
# Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
|
||||
# when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
|
||||
# onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
|
||||
# Default false to preserve current behaviour for upgraders.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
|
||||
|
||||
# Per-VP opt-in for auto-print G-code injection (#1516). Default false so
|
||||
# existing gcode_snippets users don't silently start injecting on VP/Studio
|
||||
# Send jobs after upgrading.
|
||||
|
|
@ -3689,6 +3808,14 @@ async def run_migrations(conn):
|
|||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
|
||||
|
||||
# Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
|
||||
# provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
|
||||
# rejects ``DEFAULT 0`` for BOOLEAN columns.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
|
||||
|
||||
# Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
|
||||
# the start-watchdog reverted the row from 'printing' back to 'pending' so a
|
||||
# printer that never actually starts stops being retried forever. INTEGER
|
||||
|
|
@ -3768,10 +3895,60 @@ async def run_migrations(conn):
|
|||
"ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
|
||||
)
|
||||
|
||||
# Migration: real filesystem mtime for library files/folders (#2680). The
|
||||
# folder tree's "sort by recent activity" and the file pane's date sort must
|
||||
# track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
|
||||
# a bulk external scan every row's ``updated_at`` is the same scan instant, so
|
||||
# ordering was arbitrary. Nullable; the timestamp type differs by dialect
|
||||
# (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
|
||||
# "type datetime does not exist" on Postgres. On a fresh DB create_all() already
|
||||
# built the column, so the ALTER is swallowed as "already exists".
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
|
||||
await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
|
||||
await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
|
||||
|
||||
# Migration: Disambiguate the four ``user_print_*`` notification template
|
||||
# names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
|
||||
await _migrate_rename_user_print_template_names(conn)
|
||||
|
||||
# Migration: per-file print progress inside a project (#1897).
|
||||
# - print_archives.library_file_id: which library file a queued run was
|
||||
# dispatched from; nullable, no FK constraint added to existing tables
|
||||
# (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
|
||||
# via the ORM on fresh installs and tolerates dangling ids by matching
|
||||
# hash/filename as fallback anyway).
|
||||
# - projects.target_sets: optional copies-per-file target. INTEGER is
|
||||
# spelled identically on SQLite and Postgres — no dialect branch.
|
||||
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
|
||||
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
|
||||
|
||||
# Migration: persist the timelapse snapshot-diff baseline (#2704).
|
||||
# The list of video filenames present on the printer when the print began,
|
||||
# so the diff survives a restart and the manual scan can use it instead of
|
||||
# the clock-based matching that a LAN-only printer defeats. No dialect
|
||||
# branch: SQLAlchemy renders this column as `JSON` on both SQLite and
|
||||
# Postgres for a fresh install (checked with CreateTable against each
|
||||
# dialect), so spelling the ALTER the same way keeps a migrated database
|
||||
# identical to a new one. Matching matters on Postgres in particular —
|
||||
# asyncpg binds the serialised value as json and would reject a TEXT column
|
||||
# (mirrors the `projects.attachments JSON` migration above).
|
||||
await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
|
||||
|
||||
# Migration: plate-clear-required notification opt-in (#2525). Off by
|
||||
# default — it fires after every print, at the same moment as the
|
||||
# print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
|
||||
if is_sqlite():
|
||||
await _safe_execute(
|
||||
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
|
||||
)
|
||||
else:
|
||||
await _safe_execute(
|
||||
conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
|
||||
)
|
||||
|
||||
|
||||
_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
|
||||
("user_print_start", "User Print Started", "User Print Started Email"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Logging filters for the Bambuddy log pipeline.
|
||||
"""Logging filters and redaction helpers for the Bambuddy log pipeline.
|
||||
|
||||
Holds two filters: ``WriteRequestsOnlyFilter`` keeps the file-side
|
||||
uvicorn access log focused on state-changing HTTP methods, and
|
||||
|
|
@ -6,12 +6,58 @@ uvicorn access log focused on state-changing HTTP methods, and
|
|||
caused by Starlette's ``BaseHTTPMiddleware`` cancellation propagation
|
||||
(see the filter's docstring for details). Both live here so tests can
|
||||
import them without pulling in ``backend.app.main``'s startup graph.
|
||||
|
||||
Also holds :data:`URL_CREDENTIALS_PATTERN` and
|
||||
:func:`redact_url_credentials`, the single place where the shape of a
|
||||
credentialed URL is defined for the whole backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
# ``scheme://user:secret@host`` — the only URL shape that carries a secret.
|
||||
# Both userinfo parts exclude ``/`` so the match can never run past the
|
||||
# authority into the path, and exclude whitespace so a wrapped log line can't
|
||||
# glue two URLs together. ``secret`` is otherwise unrestricted and greedy so
|
||||
# it reaches the *last* ``@`` before the path, which is where RFC 3986 ends
|
||||
# the userinfo — that keeps an unescaped ``@`` inside a password (legal in an
|
||||
# external camera URL) from leaving its tail in the log. Named groups let
|
||||
# callers choose how much to mask: the log pipeline keeps the username, the
|
||||
# support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
|
||||
#
|
||||
# The scheme's repetition is bounded deliberately. As an unbounded ``*`` the
|
||||
# match was quadratic in the length of the subject (CodeQL py/polynomial-redos):
|
||||
# on a long run of scheme-legal characters the engine restarts at every offset
|
||||
# and consumes to the end each time before failing to find ``://``. Measured at
|
||||
# 557ms for a 32KB line, quadrupling per doubling. ffmpeg echoes the operator's
|
||||
# camera URL back in its stderr, and that whole string reaches this pattern
|
||||
# before any truncation, so the subject length is attacker-influenced. A cap
|
||||
# makes the work per offset constant. 63 is far above any real scheme (the
|
||||
# longest registered one is under 20 characters), and a longer pseudo-scheme
|
||||
# still gets its secret masked — the match simply starts from a later offset.
|
||||
URL_CREDENTIALS_PATTERN = re.compile(
|
||||
r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
|
||||
)
|
||||
|
||||
|
||||
def redact_url_credentials(text: str | None) -> str | None:
|
||||
"""Mask the password in every ``scheme://user:secret@host`` URL in *text*.
|
||||
|
||||
Subprocesses echo their input URL back at us — ffmpeg prints the RTSP
|
||||
input in its ``Input #0`` line, so logging its stderr verbatim publishes
|
||||
the printer access code (or an external camera's password) into
|
||||
``bambuddy.log``, which users routinely attach to public issues.
|
||||
|
||||
The username, host, port and path survive so the line stays useful for
|
||||
diagnosis; only the secret is replaced. Returns *text* unchanged when
|
||||
there is nothing to mask, including ``None``/``""``.
|
||||
"""
|
||||
if not text or "://" not in text or "@" not in text:
|
||||
return text
|
||||
return URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>\g<user>:[REDACTED]@", text)
|
||||
|
||||
|
||||
class WriteRequestsOnlyFilter(logging.Filter):
|
||||
|
|
|
|||
278
backend/app/core/oidc_env.py
Normal file
278
backend/app/core/oidc_env.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
|
||||
|
||||
A declarative deployment (compose, Helm, GitOps) has no way to click through
|
||||
the settings UI, so one provider can be configured entirely from the
|
||||
environment. This module only reads and defaults; validity is decided by the
|
||||
same OIDCProviderCreate schema the API uses, so env config cannot bypass a
|
||||
check the UI enforces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# All four or nothing: a provider missing its secret would be written to the
|
||||
# database and then fail at authorize time, long after the operator could
|
||||
# connect the failure to a typo in their compose file.
|
||||
_REQUIRED = (
|
||||
"BAMBUDDY_OIDC_NAME",
|
||||
"BAMBUDDY_OIDC_ISSUER_URL",
|
||||
"BAMBUDDY_OIDC_CLIENT_ID",
|
||||
"BAMBUDDY_OIDC_CLIENT_SECRET",
|
||||
)
|
||||
|
||||
_TRUTHY = {"true", "1", "yes"}
|
||||
_FALSY = {"false", "0", "no"}
|
||||
|
||||
|
||||
class EnvOIDCConfigError(Exception):
|
||||
"""A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
|
||||
boolean variable's name and value -- booleans are not secret, so the message
|
||||
is safe to log in full (unlike client_secret, which never reaches here)."""
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
|
||||
"""Parse a boolean env var. Absent or blank -> default (empty == unset).
|
||||
|
||||
strict (the default): an unrecognized non-empty value raises
|
||||
EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
|
||||
the wrong thing. strict=False: an unrecognized value falls back to the
|
||||
default instead -- for a caller on a request path where a raise would be a
|
||||
500, not a skipped startup config (see _local_login_env_bypass).
|
||||
"""
|
||||
value = os.environ.get(key)
|
||||
if value is None or value.strip() == "":
|
||||
return default # absent or blank == unset -> default, per the module's promise
|
||||
norm = value.strip().lower()
|
||||
if norm in _TRUTHY:
|
||||
return True
|
||||
if norm in _FALSY:
|
||||
return False
|
||||
if strict:
|
||||
raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
|
||||
return default
|
||||
|
||||
|
||||
def read_env_oidc_config() -> dict | None:
|
||||
"""The provider's fields from the environment, or None if it isn't configured.
|
||||
|
||||
An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
|
||||
a compose file is a forgotten value, not an intentional empty secret. Blank
|
||||
means blank *after* stripping, and the surviving value is stripped too: a
|
||||
Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
|
||||
created from a file carries a trailing newline that nothing downstream
|
||||
rejects -- max_length is the only bound the schema puts on these four. An
|
||||
issuer_url with a trailing newline is stored and enabled, and then fails
|
||||
with httpx.InvalidURL on the first click of the SSO button, which is the
|
||||
authorize-time failure the all-or-nothing rule above exists to prevent.
|
||||
"""
|
||||
required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
|
||||
if not all(required.values()):
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": required["BAMBUDDY_OIDC_NAME"],
|
||||
"issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
|
||||
"client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
|
||||
"client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
|
||||
"scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
|
||||
"is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
|
||||
"auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
|
||||
"auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
|
||||
"email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
|
||||
"require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
|
||||
"icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
|
||||
"is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
|
||||
# A name, not an id: ids are assigned per install, so the same compose
|
||||
# file would point at a different group on every deployment. Resolved
|
||||
# against the database in apply_env_oidc_provider -- the reader has no
|
||||
# session and stays dumb.
|
||||
"default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
# Everything the schema validates and the model stores, except client_secret --
|
||||
# that one goes through the property so it is encrypted at rest.
|
||||
_APPLIED_FIELDS = (
|
||||
"name",
|
||||
"issuer_url",
|
||||
"client_id",
|
||||
"scopes",
|
||||
"is_enabled",
|
||||
"auto_create_users",
|
||||
"auto_link_existing_accounts",
|
||||
"email_claim",
|
||||
"require_email_verified",
|
||||
"icon_url",
|
||||
"is_autologin",
|
||||
# Written on every boot, so a group that is no longer declared is cleared:
|
||||
# the environment is the whole truth for this row, and the API lock means
|
||||
# a lingering value could not be removed in the UI either.
|
||||
"default_group_id",
|
||||
)
|
||||
|
||||
|
||||
async def apply_env_oidc_provider(db: AsyncSession) -> None:
|
||||
"""Upsert the env-managed provider, or release it when the config is gone.
|
||||
|
||||
Never raises: this runs during startup, and a typo in one variable -- or a
|
||||
DB error on commit -- must not stop the app from booting. A rejected
|
||||
config is logged and skipped.
|
||||
"""
|
||||
try:
|
||||
await _apply_env_oidc_provider(db)
|
||||
except Exception as exc: # noqa: BLE001 -- startup must survive any failure here
|
||||
# Never str(exc): a DB error message can echo a configured value. Class only.
|
||||
logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
|
||||
# A commit may have half-applied; roll back so the shared session is
|
||||
# left clean for the rest of startup. Suppressed because rollback on a
|
||||
# wedged connection can itself raise -- and the whole point here is that
|
||||
# nothing in this path takes the boot down. The session is discarded by
|
||||
# the caller's `async with` regardless.
|
||||
with contextlib.suppress(Exception):
|
||||
await db.rollback()
|
||||
|
||||
|
||||
async def _apply_env_oidc_provider(db: AsyncSession) -> None:
|
||||
# Imported here rather than at module scope: app.core is imported by the
|
||||
# models themselves, so a top-level import would be a cycle.
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
from backend.app.schemas.auth import OIDCProviderCreate
|
||||
|
||||
try:
|
||||
config = read_env_oidc_config()
|
||||
except EnvOIDCConfigError as exc:
|
||||
# Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
|
||||
# log clearly and leave any running provider as it was. Safe to log the
|
||||
# full message -- EnvOIDCConfigError only ever carries a boolean var.
|
||||
logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
|
||||
return
|
||||
|
||||
if config is None:
|
||||
# Nothing to look up by name any more, so the previously managed rows are
|
||||
# found by the flag -- and then released. All of them: the upsert's sweep
|
||||
# should keep that at one, but scalar_one_or_none() would raise
|
||||
# MultipleResultsFound out of the lifespan the moment it isn't, and
|
||||
# losing the boot is too steep a price for an invariant check.
|
||||
released_rows = (
|
||||
(await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
|
||||
)
|
||||
for released in released_rows:
|
||||
# Disabled, never deleted: user_oidc_links.provider_id is FK ON
|
||||
# DELETE CASCADE, so removing the row would unlink every bound
|
||||
# account and the links would not come back when the variables do.
|
||||
# The flag is cleared as well: with no config behind it, a provider
|
||||
# the API still refuses to edit or delete would be a dead end
|
||||
# reachable only through the database.
|
||||
released.is_enabled = False
|
||||
released.is_env_managed = False
|
||||
# Cleared too, or the released row keeps a latent autologin claim:
|
||||
# update_oidc_provider only re-runs the exclusivity sweep when a
|
||||
# request sets is_autologin=True, so re-enabling this row in the UI
|
||||
# would silently make it the autologin target again.
|
||||
released.is_autologin = False
|
||||
logger.info(
|
||||
"BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
|
||||
released.name,
|
||||
)
|
||||
if released_rows:
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# Identity is the name, which is unique on the table. Matching on the flag
|
||||
# instead meant an operator who named the env provider after one that
|
||||
# already existed hit that unique constraint during startup -- and this
|
||||
# function runs in the lifespan, so the app would not boot.
|
||||
existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
|
||||
|
||||
# Resolved before anything is written, so a name that matches no group
|
||||
# leaves the running provider untouched. Refused rather than defaulted:
|
||||
# falling back would put every auto-created user in Viewers (routes/mfa.py)
|
||||
# for as long as the typo lives, and the API answers 422 for a
|
||||
# default_group_id that does not exist -- env config gets the same answer.
|
||||
group_name = config.pop("default_group", None)
|
||||
if group_name is not None:
|
||||
group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
|
||||
if group is None:
|
||||
# Spelled out because the two cases differ sharply: an existing
|
||||
# provider keeps running on its last good config, while on a first
|
||||
# boot nothing is created at all and the login page has no SSO
|
||||
# button until the name matches.
|
||||
logger.error(
|
||||
"BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
|
||||
group_name,
|
||||
"previous config left running" if existing is not None else "no provider created",
|
||||
)
|
||||
return
|
||||
config["default_group_id"] = group.id
|
||||
|
||||
try:
|
||||
# The same schema the API uses, so env config cannot reach a state the
|
||||
# UI would have refused (notably the SEC-1 auto-link check).
|
||||
validated = OIDCProviderCreate(**config)
|
||||
except ValidationError as exc:
|
||||
# errors(include_input=False) strips the submitted values -- str(exc)
|
||||
# embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
|
||||
logger.error(
|
||||
"BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
|
||||
exc.errors(include_input=False),
|
||||
)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 -- any rejection must be survivable
|
||||
# Log only the exception class, never str(exc): an unexpected error here
|
||||
# could carry a configured value in its message. Structural guarantee,
|
||||
# not one contingent on which exceptions the schema validators raise.
|
||||
logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
|
||||
return
|
||||
|
||||
# Computed before `existing` is reassigned below: a freshly-created row is
|
||||
# not an adoption, and a found row that was already env-managed is a
|
||||
# routine re-apply -- only a found row that the UI created is an adoption.
|
||||
adopted_ui_provider = existing is not None and not existing.is_env_managed
|
||||
|
||||
if existing is None:
|
||||
existing = OIDCProvider(is_env_managed=True)
|
||||
db.add(existing)
|
||||
for field in _APPLIED_FIELDS:
|
||||
setattr(existing, field, getattr(validated, field))
|
||||
existing.client_secret = validated.client_secret
|
||||
existing.is_env_managed = True
|
||||
await db.flush() # the id is needed by the sweeps below
|
||||
|
||||
# Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
|
||||
# stays behind. Left flagged it would keep a stale issuer and secret on the
|
||||
# login page while the API refuses every edit, disable and delete on it
|
||||
# (409) -- the dead end reachable only through the database that the release
|
||||
# path exists to prevent -- and the next release would find two rows and
|
||||
# take the boot down with MultipleResultsFound. Released, not deleted, for
|
||||
# the same cascade reason as everywhere else.
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
|
||||
.values(is_env_managed=False, is_enabled=False, is_autologin=False)
|
||||
)
|
||||
|
||||
if existing.is_autologin:
|
||||
await db.execute(
|
||||
update(OIDCProvider)
|
||||
.where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
|
||||
.values(is_autologin=False)
|
||||
)
|
||||
await db.commit()
|
||||
if adopted_ui_provider:
|
||||
logger.warning(
|
||||
"Env-managed OIDC provider %r adopted an existing UI-created provider of the "
|
||||
"same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
|
||||
existing.name,
|
||||
)
|
||||
else:
|
||||
logger.info("Env-managed OIDC provider %r applied.", existing.name)
|
||||
1244
backend/app/main.py
1244
backend/app/main.py
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,12 @@ class PrintArchive(Base):
|
|||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
|
||||
project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
|
||||
# Which library file this run was dispatched from (#1897). Set by the queue
|
||||
# scheduler when it archives a library-file print; older rows are matched by
|
||||
# content_hash/filename instead. SET NULL so deleting a file keeps history.
|
||||
library_file_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("library_files.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
# File info
|
||||
filename: Mapped[str] = mapped_column(String(255))
|
||||
|
|
@ -26,6 +32,16 @@ class PrintArchive(Base):
|
|||
# both locally and on the printer's SD after extraction — the user
|
||||
# didn't opt in to a timelapse recording.
|
||||
bambuddy_forced_timelapse: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
# Video filenames present in the printer's /timelapse directory when this
|
||||
# print started (#2704). The printer writes its video only at print end, so
|
||||
# anything not in this list belongs to this print — a comparison that needs
|
||||
# no clock, which matters because a LAN-only printer can't reach Bambu's NTP
|
||||
# server and its filename timestamps are arbitrarily wrong. Persisted (not
|
||||
# just held in memory) so the diff survives a restart and so the manual
|
||||
# "Scan for Timelapse" button can use it instead of guessing from
|
||||
# timestamps. NULL for archives predating this, and for baselines taken at
|
||||
# completion time, which are useless by construction.
|
||||
timelapse_baseline: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
source_3mf_path: Mapped[str | None] = mapped_column(String(500)) # Original project 3MF from slicer
|
||||
f3d_path: Mapped[str | None] = mapped_column(String(500)) # Fusion 360 design file
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ class LibraryFolder(Base):
|
|||
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())
|
||||
|
||||
# Real on-disk modification time of the directory this folder mirrors (#2680).
|
||||
# For external folders this is captured from ``os.stat().st_mtime`` on scan so
|
||||
# the tree's "sort by recent activity" matches ``ls -t`` instead of ordering by
|
||||
# the DB row's ``updated_at`` (which is the scan instant, identical for every
|
||||
# row of a bulk scan). Null for managed (internal) folders, which have no
|
||||
# meaningful directory mtime — callers fall back to ``updated_at``/``created_at``.
|
||||
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
parent: Mapped["LibraryFolder | None"] = relationship(
|
||||
"LibraryFolder",
|
||||
|
|
@ -102,6 +110,14 @@ class LibraryFile(Base):
|
|||
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())
|
||||
|
||||
# Real on-disk modification time of the file (#2680). Captured from
|
||||
# ``os.stat().st_mtime`` for external files on scan so the file pane's date
|
||||
# sort and the folder tree's recursive "recent activity" bubble reflect the
|
||||
# actual filesystem mtime (``ls -t``) rather than the DB ``updated_at`` (the
|
||||
# scan instant, identical across a bulk scan). Null for managed uploads —
|
||||
# callers fall back to ``created_at``.
|
||||
fs_modified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
|
||||
project: Mapped["Project | None"] = relationship()
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ class NotificationProvider(Base):
|
|||
|
||||
# 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)
|
||||
on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear
|
||||
|
||||
# Event triggers - Bed cooled after print
|
||||
on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print
|
||||
|
|
|
|||
|
|
@ -85,6 +85,12 @@ DEFAULT_TEMPLATES = [
|
|||
"title_template": "Plate Not Empty - Print Paused",
|
||||
"body_template": "{printer}: Objects detected on build plate. Print has been paused. Clear plate and resume.",
|
||||
},
|
||||
{
|
||||
"event_type": "plate_clear_required",
|
||||
"name": "Plate Clear Required",
|
||||
"title_template": "Plate Clear Required",
|
||||
"body_template": "{printer}: print finished. Confirm the build plate is clear before the queue continues.",
|
||||
},
|
||||
{
|
||||
"event_type": "filament_low",
|
||||
"name": "Filament Low",
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ class OIDCProvider(Base):
|
|||
# authorize-URL fetch fails or times out, and ``/login?fallback=local``
|
||||
# plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
|
||||
is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
# Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
|
||||
# startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
|
||||
# FK is ON DELETE CASCADE).
|
||||
is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
|
||||
|
||||
@property
|
||||
def has_icon(self) -> bool:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ class Project(Base):
|
|||
target_parts_count: Mapped[int | None] = mapped_column(
|
||||
Integer, nullable=True
|
||||
) # Optional target number of parts/objects
|
||||
# Optional copies-per-file target (#1897): every printable file in the
|
||||
# project's linked folders should be printed this many times ("sets").
|
||||
target_sets: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Phase 2: Rich text notes (HTML from WYSIWYG editor)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,18 @@ class VirtualPrinter(Base):
|
|||
) # queue mode: pin per-slot type+color from the 3MF onto the queue
|
||||
# item so the scheduler refuses to dispatch onto a printer with the wrong
|
||||
# filament loaded (#1188).
|
||||
save_ams_mapping: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
|
||||
# `ams_mapping` field on the MQTT `project_file` command) instead of
|
||||
# re-deriving one from the file's static type/color. Stamps it on the queue
|
||||
# item so THIS print dispatches to those trays, and onto the archive's
|
||||
# `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
|
||||
# physical spools. Off by default: taking the slicer's pick makes the
|
||||
# scheduler skip `_compute_ams_mapping_for_printer`, and with it
|
||||
# `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
|
||||
# inventory-remain overrides — so it stays opt-in per virtual printer
|
||||
# rather than changing behaviour for upgraders (#2700).
|
||||
gcode_injection: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ class ArchiveSlim(BaseModel):
|
|||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
cost: float | None
|
||||
energy_kwh: float | None = None
|
||||
energy_cost: float | None = None
|
||||
quantity: int = 1
|
||||
created_at: datetime | None
|
||||
|
||||
|
|
|
|||
|
|
@ -360,28 +360,37 @@ def _validate_icon_url(v: str | None) -> str | None:
|
|||
|
||||
|
||||
def _validate_issuer_url(v: str | None) -> str | None:
|
||||
"""Nit4: Reject non-HTTPS issuer URLs and private/loopback/link-local hosts.
|
||||
"""Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
|
||||
|
||||
HTTP is no longer accepted — OIDC providers must be reachable over TLS.
|
||||
Private-network and loopback addresses are rejected to prevent SSRF attacks
|
||||
where an admin-supplied URL could reach internal services.
|
||||
An OIDC provider must be reachable over TLS on the public internet, so
|
||||
this uses the public-internet policy: private, loopback and link-local
|
||||
addresses are all rejected.
|
||||
|
||||
Delegates to the runtime guard ``assert_safe_public_https_url`` for the
|
||||
same reason ``_validate_icon_url`` does — no policy drift between the
|
||||
schema layer and the fetcher. The hand-rolled version this replaced
|
||||
checked only ``is_private | is_loopback | is_link_local``, which left
|
||||
numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
|
||||
(``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
|
||||
able to express a target the policy meant to forbid. The guard's
|
||||
docstring already claimed the two were consistent; now they are.
|
||||
|
||||
Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
|
||||
schemas avoid top-level imports from that layer.
|
||||
"""
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith("https://"):
|
||||
raise ValueError("issuer_url must start with https://")
|
||||
host = urlparse(v).hostname or ""
|
||||
from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(host)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
raise ValueError("issuer_url must not point to a private, loopback, or link-local address")
|
||||
assert_safe_public_https_url(v)
|
||||
except ValueError as exc:
|
||||
if "issuer_url" in str(exc):
|
||||
raise
|
||||
# hostname is a domain name, not a bare IP — that's fine
|
||||
# The guard's messages say "icon URL" — rewrite for this field so the
|
||||
# user sees the setting they actually submitted.
|
||||
detail = str(exc).replace("icon URL", "issuer_url")
|
||||
raise ValueError(detail) from exc
|
||||
return v
|
||||
|
||||
|
||||
|
|
@ -518,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
|
|||
icon_url: str | None = None
|
||||
default_group_id: int | None = None
|
||||
is_autologin: bool = False # #1589
|
||||
# #2593 — the UI renders this provider read-only; without the flag it would
|
||||
# offer editable fields whose writes the API then refuses with 409.
|
||||
is_env_managed: bool = False
|
||||
# Set explicitly in the route handler from `icon_content_type is not None`
|
||||
# rather than `@computed_field` (project policy) or `icon_data is not None`
|
||||
# (would trigger an async lazy-load on the deferred BLOB column).
|
||||
|
|
|
|||
|
|
@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class CloudAccountCounts(BaseModel):
|
||||
"""How many connected cloud accounts a backup would collect presets from.
|
||||
|
||||
Counts only, never identities: with auth enabled these are other users'
|
||||
accounts, and whoever administers the backup has no business learning who
|
||||
signed in to what. The number is enough to answer the only question the UI
|
||||
asks — is the Cloud Profiles category worth offering at all (#2717).
|
||||
"""
|
||||
|
||||
bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
|
||||
orca: int = Field(default=0, description="Connected Orca Cloud accounts")
|
||||
|
||||
|
||||
class GitHubBackupStatus(BaseModel):
|
||||
"""Schema for current backup status."""
|
||||
|
||||
|
|
|
|||
|
|
@ -205,6 +205,10 @@ class FileListResponse(BaseModel):
|
|||
created_by_id: int | None = None
|
||||
created_by_username: str | None = None
|
||||
created_at: datetime
|
||||
# Real on-disk modification time (#2680). Populated for external files from
|
||||
# their filesystem mtime; null for managed uploads. The file pane's date sort
|
||||
# and the "Modified" column use ``fs_modified_at ?? created_at``.
|
||||
fs_modified_at: datetime | None = None
|
||||
|
||||
# Key metadata fields for display
|
||||
print_name: str | None = None
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ProviderType(StrEnum):
|
|||
DISCORD = "discord"
|
||||
WEBHOOK = "webhook"
|
||||
HOMEASSISTANT = "homeassistant"
|
||||
BARK = "bark"
|
||||
|
||||
|
||||
class NotificationProviderBase(BaseModel):
|
||||
|
|
@ -62,6 +63,9 @@ class NotificationProviderBase(BaseModel):
|
|||
|
||||
# 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(
|
||||
default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
|
||||
)
|
||||
|
||||
# Event triggers - Bed cooled
|
||||
on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
|
||||
|
|
@ -146,6 +150,7 @@ class NotificationProviderUpdate(BaseModel):
|
|||
|
||||
# Event triggers - Build plate detection
|
||||
on_plate_not_empty: bool | None = None
|
||||
on_plate_clear_required: bool | None = None
|
||||
|
||||
# Event triggers - Bed cooled
|
||||
on_bed_cooled: bool | None = None
|
||||
|
|
|
|||
|
|
@ -194,6 +194,12 @@ class PrintQueueItemResponse(BaseModel):
|
|||
# 3MFs: when `plate_id` is set, the value is the matching plate's
|
||||
# `curr_bed_type` rather than the archive-level first-plate default.
|
||||
bed_type: str | None = None
|
||||
# True when the source archive carries the slicer's own live-resolved
|
||||
# AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
|
||||
# against this row's own printer — the only case where dispatch actually
|
||||
# reuses that exact physical spool instead of the scheduler re-deriving one
|
||||
# from the file's static type/color.
|
||||
archive_has_slicer_ams_mapping: bool = False
|
||||
|
||||
# User tracking (Issue #206)
|
||||
created_by_id: int | None = None
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ from datetime import datetime
|
|||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from backend.app.utils.printer_models import supports_nozzle_flow_type
|
||||
|
||||
|
||||
class PrinterBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
|
|
@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
|
|||
id: int
|
||||
is_active: bool
|
||||
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
|
||||
# Whether the model is sold with both Standard and High Flow nozzles, so a
|
||||
# K-profile's flow type is a real choice rather than a meaningless field.
|
||||
# Derived from the model, not from nozzle_count — see
|
||||
# printer_models.supports_nozzle_flow_type.
|
||||
supports_nozzle_flow_type: bool = True
|
||||
print_hours_offset: float = 0.0
|
||||
external_camera_url: str | None = None
|
||||
external_camera_type: str | None = None
|
||||
|
|
@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
|
|||
"camera_rotation": printer.camera_rotation,
|
||||
"is_active": printer.is_active,
|
||||
"nozzle_count": printer.nozzle_count,
|
||||
"supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
|
||||
"print_hours_offset": printer.print_hours_offset,
|
||||
"plate_detection_enabled": printer.plate_detection_enabled,
|
||||
"created_at": printer.created_at,
|
||||
|
|
@ -360,6 +368,11 @@ class PrinterStatus(BaseModel):
|
|||
big_fan1_speed: int | None = None # Auxiliary fan
|
||||
big_fan2_speed: int | None = None # Chamber/exhaust fan
|
||||
heatbreak_fan_speed: int | None = None # Hotend heatbreak fan
|
||||
# Left auxiliary part cooling fan (optional P2S/X2D accessory, airduct part id 10).
|
||||
# None = not installed / not reported by this model.
|
||||
left_aux_fan_speed: int | None = None
|
||||
# Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit; airduct part id 3).
|
||||
exhaust_fan_present: bool = False
|
||||
# Firmware version (from info.module[name="ota"].sw_ver)
|
||||
firmware_version: str | None = None
|
||||
# Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class ProjectCreate(BaseModel):
|
|||
color: str | None = None
|
||||
target_count: int | None = None
|
||||
target_parts_count: int | None = None
|
||||
target_sets: int | None = None # Copies-per-file target (#1897)
|
||||
notes: str | None = None
|
||||
tags: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
|
@ -49,6 +50,7 @@ class ProjectUpdate(BaseModel):
|
|||
status: str | None = None # active, completed, archived
|
||||
target_count: int | None = None
|
||||
target_parts_count: int | None = None
|
||||
target_sets: int | None = None # Copies-per-file target (#1897)
|
||||
notes: str | None = None
|
||||
tags: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
|
@ -108,6 +110,7 @@ class ProjectResponse(BaseModel):
|
|||
status: str
|
||||
target_count: int | None
|
||||
target_parts_count: int | None = None
|
||||
target_sets: int | None = None # Copies-per-file target (#1897)
|
||||
notes: str | None = None
|
||||
attachments: list | None = None
|
||||
tags: str | None = None
|
||||
|
|
@ -129,6 +132,13 @@ class ProjectResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectFileProgress(BaseModel):
|
||||
"""Completed-run count for one library file inside a project (#1897)."""
|
||||
|
||||
file_id: int
|
||||
completed_count: int
|
||||
|
||||
|
||||
class ArchivePreview(BaseModel):
|
||||
"""Minimal archive data for project preview."""
|
||||
|
||||
|
|
@ -150,6 +160,7 @@ class ProjectListResponse(BaseModel):
|
|||
status: str
|
||||
target_count: int | None
|
||||
target_parts_count: int | None = None
|
||||
target_sets: int | None = None # Copies-per-file target (#1897); the shared edit dialog needs it
|
||||
budget: float | None = None
|
||||
# The edit dialog is shared with the project detail page and seeds its fields
|
||||
# from whichever project object it is handed, so the list payload has to carry
|
||||
|
|
@ -276,6 +287,7 @@ class ProjectExport(BaseModel):
|
|||
status: str
|
||||
target_count: int | None
|
||||
target_parts_count: int | None
|
||||
target_sets: int | None = None
|
||||
notes: str | None
|
||||
tags: str | None
|
||||
due_date: datetime | None
|
||||
|
|
@ -294,6 +306,7 @@ class ProjectImport(BaseModel):
|
|||
status: str = "active"
|
||||
target_count: int | None = None
|
||||
target_parts_count: int | None = None
|
||||
target_sets: int | None = None
|
||||
notes: str | None = None
|
||||
tags: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
|
|
|||
|
|
@ -1,9 +1,23 @@
|
|||
import json
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, ValidationInfo, field_validator
|
||||
|
||||
from backend.app.schemas.print_queue import TriState
|
||||
|
||||
# Outbound service URLs validated on save, so a bad value is rejected at
|
||||
# configuration time with a clear message rather than failing opaquely at
|
||||
# request time. Every one of these services is commonly self-hosted on the same
|
||||
# host or LAN as Bambuddy, so the LAN-service policy applies: loopback and
|
||||
# RFC-1918 stay permitted, while cloud-metadata endpoints, numeric-encoded IPs,
|
||||
# IPv4-mapped IPv6 and non-HTTP schemes are rejected. See
|
||||
# ``_url_safety.assert_safe_lan_service_url``.
|
||||
#
|
||||
# Module-level rather than a class attribute so the CI backstop in
|
||||
# tests/unit/test_outbound_url_ssrf_guards.py can import the real list and
|
||||
# cannot drift from it. Any new outbound-URL setting belongs here (or, if it
|
||||
# must be reachable on the public internet, on the stricter OIDC guard).
|
||||
LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
"""Application settings schema."""
|
||||
|
|
@ -19,6 +33,16 @@ class AppSettings(BaseModel):
|
|||
"this print, otherwise it is deleted automatically after the photo is captured."
|
||||
),
|
||||
)
|
||||
finish_photo_restore_plate: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Raise the build plate back into camera framing before taking the finish photo. "
|
||||
"Bambu's end G-code drops the plate ~100mm as the last thing it does, leaving the "
|
||||
"finished print far below the camera's natural framing. Bambuddy moves it back to "
|
||||
"just above the last printed layer, takes the photo, then lowers it again. Skipped "
|
||||
"when the print height is unknown or another job is queued for the printer."
|
||||
),
|
||||
)
|
||||
default_filament_cost: float = Field(default=25.0, description="Default filament cost per kg")
|
||||
currency: str = Field(default="USD", description="Currency for cost tracking")
|
||||
energy_cost_per_kwh: float = Field(default=0.15, description="Electricity cost per kWh for energy tracking")
|
||||
|
|
@ -261,6 +285,21 @@ class AppSettings(BaseModel):
|
|||
default="",
|
||||
description="BambuStudio sidecar URL (e.g. http://localhost:3001). Empty falls back to the BAMBU_STUDIO_API_URL env var.",
|
||||
)
|
||||
# How long to keep waiting on a slice that isn't finishing. Measured against
|
||||
# the sidecar's progress channel, not total elapsed time — a heavy model can
|
||||
# legitimately slice for half an hour, and a wall-clock ceiling cannot tell
|
||||
# that apart from a stalled one (#2730). Sidecars too old to report progress
|
||||
# fall back to using this as a total-elapsed ceiling, which is the pre-#2730
|
||||
# behaviour with a configurable number.
|
||||
slicer_stall_timeout_minutes: int = Field(
|
||||
default=15,
|
||||
ge=1,
|
||||
le=240,
|
||||
description=(
|
||||
"Give up on a slice after this many minutes with no progress from the sidecar. "
|
||||
"On sidecars that do not report progress, applies to total slicing time instead."
|
||||
),
|
||||
)
|
||||
|
||||
# Prometheus metrics endpoint
|
||||
prometheus_enabled: bool = Field(default=False, description="Enable Prometheus metrics endpoint at /metrics")
|
||||
|
|
@ -443,6 +482,13 @@ class AppSettings(BaseModel):
|
|||
default="",
|
||||
description="Self-hosted Obico ML API base URL (e.g., http://192.168.1.10:3333)",
|
||||
)
|
||||
obico_ml_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Bearer token for the Obico ML API, matching the server's ML_API_TOKEN "
|
||||
"environment variable. Empty when the server runs without one."
|
||||
),
|
||||
)
|
||||
obico_sensitivity: str = Field(
|
||||
default="medium",
|
||||
description="Detection sensitivity: 'low', 'medium', or 'high' (adjusts LOW/HIGH thresholds)",
|
||||
|
|
@ -482,6 +528,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
auto_archive: bool | None = None
|
||||
save_thumbnails: bool | None = None
|
||||
capture_finish_photo: bool | None = None
|
||||
finish_photo_restore_plate: bool | None = None
|
||||
default_filament_cost: float | None = None
|
||||
currency: str | None = None
|
||||
energy_cost_per_kwh: float | None = None
|
||||
|
|
@ -551,6 +598,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
use_slicer_api: bool | None = None
|
||||
orcaslicer_api_url: str | None = None
|
||||
bambu_studio_api_url: str | None = None
|
||||
slicer_stall_timeout_minutes: int | None = Field(default=None, ge=1, le=240)
|
||||
prometheus_enabled: bool | None = None
|
||||
prometheus_token: str | None = None
|
||||
low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
|
||||
|
|
@ -593,6 +641,7 @@ class AppSettingsUpdate(BaseModel):
|
|||
ldap_default_group: str | None = None
|
||||
obico_enabled: bool | None = None
|
||||
obico_ml_url: str | None = None
|
||||
obico_ml_token: str | None = None
|
||||
obico_sensitivity: str | None = None
|
||||
obico_action: str | None = None
|
||||
obico_poll_interval: int | None = Field(default=None, ge=5, le=120)
|
||||
|
|
@ -600,6 +649,47 @@ class AppSettingsUpdate(BaseModel):
|
|||
default_sidebar_order: str | None = None
|
||||
forecast_global_lead_time_days: int | None = Field(default=None, ge=0)
|
||||
|
||||
@field_validator(*LAN_SERVICE_URL_SETTINGS)
|
||||
@classmethod
|
||||
def validate_lan_service_url(cls, v: str | None, info: ValidationInfo) -> str | None:
|
||||
"""Reject SSRF-unsafe outbound service URLs on save.
|
||||
|
||||
Empty (and whitespace-only) is the documented "not configured / fall
|
||||
back to the env var" value for all four fields and must keep passing.
|
||||
|
||||
Values that are not absolute URLs at all ("192.168.1.10:3333",
|
||||
"localhost:3333") are left alone rather than rejected. Two reasons:
|
||||
|
||||
- They are inert. Every consumer of these four settings goes through
|
||||
httpx, which raises UnsupportedProtocol for a URL with no scheme, so
|
||||
no request is ever issued and there is nothing to guard against.
|
||||
- They were storable before this validator existed, and the settings
|
||||
UI is a plain text input with no scheme enforcement. Newly rejecting
|
||||
them would break saves that have nothing to do with the URL: the
|
||||
Obico panel, for one, sends obico_ml_url with every change and
|
||||
auto-saves, so one legacy value would block toggling detection on or
|
||||
off. A pre-existing misconfiguration should keep failing where it
|
||||
already failed (at request time), not spread to unrelated fields.
|
||||
|
||||
``urlparse`` is no help in telling the two apart — it reads
|
||||
"localhost:3333" as scheme "localhost" — so the test is the literal
|
||||
"://" that makes a string an absolute URL.
|
||||
"""
|
||||
if v is None or not v.strip():
|
||||
return v
|
||||
candidate = v.strip()
|
||||
if "://" not in candidate:
|
||||
return v
|
||||
# Lazy-imported: schemas avoid top-level imports from api/routes,
|
||||
# matching the existing pattern in auth.py's _validate_icon_url.
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
assert_safe_lan_service_url(candidate, label=info.field_name or "URL")
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return v
|
||||
|
||||
@field_validator("gcode_snippets")
|
||||
@classmethod
|
||||
def validate_gcode_snippets(cls, v: str | None) -> str | None:
|
||||
|
|
|
|||
|
|
@ -82,6 +82,17 @@ class SliceRequest(BaseModel):
|
|||
default=False,
|
||||
description="If true, request a 3MF response with embedded G-code instead of raw G-code.",
|
||||
)
|
||||
design_overrides: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"3MF only. Process setting keys from the source file's "
|
||||
"``different_settings_to_system`` to carry onto the picked process "
|
||||
"preset (#2622) — the designer's own wall count, infill, first-layer "
|
||||
"height and so on, which ``--load-settings`` would otherwise discard. "
|
||||
"Only keys the source actually lists as changed are applied; anything "
|
||||
"else is ignored. ``None``/empty means a plain profile slice."
|
||||
),
|
||||
)
|
||||
use_embedded_settings: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -1143,6 +1143,9 @@ class ArchiveService:
|
|||
subtask_id: str | None = None,
|
||||
prefer_filename_for_name: bool = False,
|
||||
plate_id: int | None = None,
|
||||
library_file_id: int | None = None,
|
||||
slicer_ams_mapping: list[int] | None = None,
|
||||
slicer_ams_mapping_printer_id: int | None = None,
|
||||
) -> PrintArchive | None:
|
||||
"""Archive a 3MF file with metadata.
|
||||
|
||||
|
|
@ -1155,6 +1158,8 @@ class ArchiveService:
|
|||
stored with UUID names)
|
||||
project_id: Project to associate this archive with (optional, set when triggered
|
||||
from the project view)
|
||||
library_file_id: Library file this run was dispatched from (optional,
|
||||
set by the queue scheduler — powers per-file project progress, #1897)
|
||||
subtask_id: MQTT-provided task identifier (optional). Used to match an
|
||||
existing archive across a backend restart mid-print so the
|
||||
original row can be resumed instead of cancelled (#972).
|
||||
|
|
@ -1163,6 +1168,21 @@ class ArchiveService:
|
|||
metadata. Used by virtual-printer flows so users who rename a job in
|
||||
BambuStudio's "send to printer" dialog see that name instead of the
|
||||
creator-baked title (#1152).
|
||||
slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
|
||||
onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
|
||||
a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
|
||||
populated on every MQTT print-start callback regardless of source (bambu_mqtt's
|
||||
request-topic interception captures it for slicer-direct LAN prints too), so
|
||||
promoting it unconditionally would stamp every archive on installs with no
|
||||
virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
|
||||
"Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
|
||||
slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
|
||||
resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
|
||||
only means something relative to one printer's specific AMS layout, so a
|
||||
mapping saved without knowing which printer it came from can't be safely
|
||||
reused later on any printer, including the same one (there'd be no way to
|
||||
tell). A model-based VP with no fixed target printer has no valid value to
|
||||
pass here and must leave both params unset.
|
||||
"""
|
||||
# Verify printer exists if specified
|
||||
if printer_id is not None:
|
||||
|
|
@ -1251,6 +1271,25 @@ class ArchiveService:
|
|||
if print_data:
|
||||
metadata["_print_data"] = print_data
|
||||
|
||||
# Promote the slicer's own live-resolved AMS-slot pick, when the caller
|
||||
# explicitly opted in (see the `slicer_ams_mapping` param docstring for
|
||||
# why this is NOT read off `print_data["ams_mapping"]`), to a stable
|
||||
# top-level extra_data key. Lets a later reprint reuse the exact tray
|
||||
# the user picked/BambuStudio auto-matched at slice time instead of the
|
||||
# scheduler re-deriving one from just the file's static type/color,
|
||||
# which can land on the wrong physical spool when that match isn't
|
||||
# unique. Top-level (not nested under the `_print_data` diagnostic bag)
|
||||
# so API consumers have a single stable path:
|
||||
# `archive.extra_data.slicer_ams_mapping`. Stored together with the
|
||||
# printer it was resolved against — see `slicer_ams_mapping_printer_id`
|
||||
# param docstring — so a later reprint can tell whether it's even
|
||||
# applicable before trying to reuse it.
|
||||
if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
|
||||
metadata["slicer_ams_mapping"] = {
|
||||
"mapping": slicer_ams_mapping,
|
||||
"printer_id": slicer_ams_mapping_printer_id,
|
||||
}
|
||||
|
||||
# Determine status and timestamps
|
||||
status = print_data.get("status", "completed") if print_data else "archived"
|
||||
started_at = datetime.now(timezone.utc) if status == "printing" else None
|
||||
|
|
@ -1314,6 +1353,7 @@ class ArchiveService:
|
|||
extra_data=metadata,
|
||||
created_by_id=created_by_id,
|
||||
project_id=project_id,
|
||||
library_file_id=library_file_id,
|
||||
subtask_id=subtask_id,
|
||||
plate_id=plate_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -416,6 +416,42 @@ class BambuCloudService:
|
|||
logger.error("Email verification failed: %s", e)
|
||||
raise BambuCloudAuthError(f"Verification failed: {e}")
|
||||
|
||||
async def _fetch_csrf_token(self, web_origin: str) -> str | None:
|
||||
"""Seed the ``bbl_csrf_token`` cookie and return its value (#2696).
|
||||
|
||||
Bambu added double-submit CSRF protection to the ``bambulab.com`` web
|
||||
origin. A POST without the cookie is rejected ``403 {"error": "CSRF
|
||||
error: missing_cookie"}`` before the request body is looked at; with the
|
||||
cookie but no matching header it becomes ``missing_header``. Only
|
||||
``GET /api/csrf`` mints one — the sign-in *page* sets nothing but
|
||||
Cloudflare's ``__cf_bm``, so landing there first does not help.
|
||||
|
||||
The token is re-fetched per verification rather than cached: the client
|
||||
is process-wide and long-lived, so a stale cookie could otherwise
|
||||
disagree with the header we send.
|
||||
"""
|
||||
try:
|
||||
response = await self._client.get(
|
||||
f"{web_origin}/api/csrf",
|
||||
headers={"User-Agent": _USER_AGENT, "Accept": "application/json"},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch Bambu Cloud CSRF token: %s", e)
|
||||
return None
|
||||
# httpx stores the Set-Cookie on the shared jar, which is also what makes
|
||||
# the cookie ride along on the POST below — we only need the value here
|
||||
# to echo it back in the header.
|
||||
try:
|
||||
token = self._client.cookies.get("bbl_csrf_token")
|
||||
except Exception: # multiple cookies of the same name across domains
|
||||
token = None
|
||||
if not token:
|
||||
logger.warning(
|
||||
"Bambu Cloud CSRF endpoint returned no bbl_csrf_token (status %s)",
|
||||
response.status_code,
|
||||
)
|
||||
return token
|
||||
|
||||
async def verify_totp(self, tfa_key: str, code: str) -> dict:
|
||||
"""
|
||||
Complete login with TOTP code from authenticator app.
|
||||
|
|
@ -433,9 +469,24 @@ class BambuCloudService:
|
|||
# expected application-level "Login failed" JSON, no Cloudflare
|
||||
# interstitial). Browser-impersonation removed to stay clearly on
|
||||
# the right side of Bambu Lab's "no falsified client identity" line.
|
||||
tfa_url = "https://bambulab.com/api/sign-in/tfa"
|
||||
if "bambulab.cn" in self.base_url:
|
||||
tfa_url = "https://bambulab.cn/api/sign-in/tfa"
|
||||
web_origin = "https://bambulab.cn" if "bambulab.cn" in self.base_url else "https://bambulab.com"
|
||||
tfa_url = f"{web_origin}/api/sign-in/tfa"
|
||||
|
||||
# #2696: the web origin is CSRF-protected (double submit). Without
|
||||
# both halves the endpoint 403s before it ever evaluates the code,
|
||||
# which surfaced to users as a permanent, misleading "Invalid code".
|
||||
# api.bambulab.com — where every other call in this service goes,
|
||||
# including the email-code 2FA path — is not gated, which is why
|
||||
# only TOTP sign-ins broke.
|
||||
csrf_token = await self._fetch_csrf_token(web_origin)
|
||||
if not csrf_token:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"Could not obtain a security token from Bambu Cloud. "
|
||||
"Check the server's internet access and try again."
|
||||
),
|
||||
}
|
||||
|
||||
response = await self._client.post(
|
||||
tfa_url,
|
||||
|
|
@ -443,6 +494,10 @@ class BambuCloudService:
|
|||
"Content-Type": "application/json",
|
||||
"User-Agent": _USER_AGENT,
|
||||
"Accept": "application/json",
|
||||
# Echo of the bbl_csrf_token cookie httpx just stored. Both
|
||||
# halves are required; the cookie alone yields
|
||||
# "missing_header".
|
||||
"x-bbl-csrf-token": csrf_token,
|
||||
},
|
||||
json={
|
||||
"tfaKey": tfa_key,
|
||||
|
|
@ -487,10 +542,26 @@ class BambuCloudService:
|
|||
|
||||
# Provide helpful error message
|
||||
error_msg = data.get("message", "")
|
||||
|
||||
# A CSRF rejection means the code was never evaluated (#2696). It
|
||||
# used to fall through to the generic path below and read as
|
||||
# "Invalid code", which sent the reporter chasing clock drift and
|
||||
# leading-zero parsing for a request Bambu had already refused.
|
||||
csrf_error = data.get("error", "") if isinstance(data.get("error"), str) else ""
|
||||
if "csrf" in csrf_error.lower() or data.get("reason") in ("missing_cookie", "missing_header"):
|
||||
logger.error("Bambu Cloud rejected the TOTP request on CSRF grounds: %s", response.text[:200])
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
"Bambu Cloud rejected the sign-in request before checking your code "
|
||||
"(security-token error). Your code is fine — please try again."
|
||||
),
|
||||
}
|
||||
|
||||
if "expired" in error_msg.lower():
|
||||
return {"success": False, "message": "TOTP session expired. Please try logging in again."}
|
||||
if not error_msg:
|
||||
error_msg = f"TOTP verification failed (status {response.status_code})"
|
||||
error_msg = data.get("error") or f"TOTP verification failed (status {response.status_code})"
|
||||
|
||||
return {"success": False, "message": error_msg}
|
||||
|
||||
|
|
|
|||
|
|
@ -353,18 +353,43 @@ class BambuFTPClient:
|
|||
|
||||
return files
|
||||
|
||||
def download_file(self, remote_path: str) -> bytes | None:
|
||||
"""Download a file from the printer."""
|
||||
def download_file(self, remote_path: str, expected_size: int | None = None) -> bytes | None:
|
||||
"""Download a file from the printer.
|
||||
|
||||
``expected_size`` is the byte count the directory listing reported for
|
||||
this file. Pass it whenever a short read must not be mistaken for a
|
||||
successful download: an FTPS data connection that closes early does
|
||||
not always raise, so ``retrbinary`` can hand back a partial buffer that
|
||||
looks like a perfectly good file to everything downstream. That is
|
||||
tolerable when the printer keeps its copy, and not tolerable when the
|
||||
caller goes on to delete the source (#2704).
|
||||
|
||||
A zero-byte result is always treated as a failure, matching
|
||||
:meth:`download_to_file` — no caller has a use for an empty file.
|
||||
"""
|
||||
if not self._ftp:
|
||||
return None
|
||||
|
||||
try:
|
||||
buffer = BytesIO()
|
||||
self._ftp.retrbinary(f"RETR {remote_path}", buffer.write)
|
||||
return buffer.getvalue()
|
||||
data = buffer.getvalue()
|
||||
except (OSError, ftplib.Error):
|
||||
return None
|
||||
|
||||
if not data:
|
||||
logger.warning("FTP download returned 0 bytes for %s", remote_path)
|
||||
return None
|
||||
if expected_size is not None and len(data) != expected_size:
|
||||
logger.warning(
|
||||
"FTP download of %s is short: got %s bytes, listing reported %s — treating as failed",
|
||||
remote_path,
|
||||
len(data),
|
||||
expected_size,
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
def download_to_file(self, remote_path: str, local_path: Path) -> bool:
|
||||
"""Download a file from the printer to local filesystem."""
|
||||
if not self._ftp:
|
||||
|
|
@ -1301,6 +1326,7 @@ async def download_file_bytes_async(
|
|||
socket_timeout: float | None = None,
|
||||
printer_model: str | None = None,
|
||||
timeout: float = 300.0,
|
||||
expected_size: int | None = None,
|
||||
) -> bytes | None:
|
||||
"""Async wrapper for downloading file as bytes.
|
||||
|
||||
|
|
@ -1313,6 +1339,9 @@ async def download_file_bytes_async(
|
|||
video, gcode) which can legitimately take minutes over slow Wi-Fi —
|
||||
the cap only guards against a permanently-starved pool, not a
|
||||
slow-but-progressing transfer.
|
||||
expected_size: size from the directory listing; a mismatch fails the
|
||||
download instead of returning a truncated file. See
|
||||
:meth:`BambuFTPClient.download_file`.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
|
|
@ -1320,7 +1349,7 @@ async def download_file_bytes_async(
|
|||
client = BambuFTPClient(ip_address, access_code, timeout=socket_timeout, printer_model=printer_model)
|
||||
if client.connect():
|
||||
try:
|
||||
return client.download_file(remote_path)
|
||||
return client.download_file(remote_path, expected_size=expected_size)
|
||||
finally:
|
||||
client.disconnect()
|
||||
return None
|
||||
|
|
@ -1332,6 +1361,121 @@ async def download_file_bytes_async(
|
|||
return None
|
||||
|
||||
|
||||
async def remote_file_settled(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_path: str,
|
||||
downloaded_bytes: int,
|
||||
*,
|
||||
printer_model: str | None = None,
|
||||
) -> bool:
|
||||
"""Confirm the printer has finished writing the file we just downloaded.
|
||||
|
||||
Matching the download against the size from the directory listing proves we
|
||||
received what the listing *said*, not that the file was *finished*. The
|
||||
timelapse scan's first look happens seconds after the print ends, which is
|
||||
exactly when the printer is writing the video — so a file still growing can
|
||||
be listed at a partial size, served at that size, and pass the length check
|
||||
as a complete video (#2704).
|
||||
|
||||
That was survivable while the printer kept its copy. It isn't now that a
|
||||
successful attach deletes the source, so re-list afterwards: if the file has
|
||||
grown, what we hold is a prefix and the caller should discard it and try
|
||||
again on the next round.
|
||||
|
||||
Returns True when the remote file can no longer differ from what we hold —
|
||||
the size still matches, or the file is gone from the listing entirely and
|
||||
so cannot grow any further. Returns False when it has changed size, and on
|
||||
a listing failure, because "we could not check" must not read as "safe to
|
||||
delete".
|
||||
"""
|
||||
directory, _, name = remote_path.rpartition("/")
|
||||
files = await list_files_async(ip_address, access_code, directory or "/", printer_model=printer_model)
|
||||
if not files:
|
||||
logger.warning("[TIMELAPSE] Could not re-list %s to confirm %s is complete", directory or "/", name)
|
||||
return False
|
||||
|
||||
for f in files:
|
||||
if f.get("name") == name:
|
||||
size = f.get("size")
|
||||
if size == downloaded_bytes:
|
||||
return True
|
||||
logger.info(
|
||||
"[TIMELAPSE] %s is still being written (%s bytes now, %s when downloaded) — will retry",
|
||||
name,
|
||||
size,
|
||||
downloaded_bytes,
|
||||
)
|
||||
return False
|
||||
|
||||
# Vanished between the download and now. Nothing left that could grow, and
|
||||
# nothing left to delete either.
|
||||
logger.debug("[TIMELAPSE] %s is no longer on the printer after download", name)
|
||||
return True
|
||||
|
||||
|
||||
async def delete_archived_timelapse(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
remote_path: str,
|
||||
*,
|
||||
verified: bool,
|
||||
printer_model: str | None = None,
|
||||
printer_name: str = "",
|
||||
) -> bool:
|
||||
"""Remove a timelapse from the printer once it is safely in the archive.
|
||||
|
||||
Call this only after the attach succeeded (#2704). Keeping ``/timelapse``
|
||||
down to just the unclaimed videos is what makes the snapshot diff
|
||||
unambiguous rather than merely usually-right, and it stops P1S cards
|
||||
filling with AVIs.
|
||||
|
||||
``verified`` must say whether the downloaded byte count was checked against
|
||||
the size the directory listing reported. It is required rather than
|
||||
defaulted because this is the one irreversible step in the flow: an FTPS
|
||||
data connection that closes early does not always raise, so an unverified
|
||||
transfer can be a partial file that looks complete, and deleting the source
|
||||
would then destroy the only good copy. The check lives here rather than at
|
||||
each call site so no future caller can omit it.
|
||||
|
||||
Best-effort otherwise: a printer that refuses the delete keeps its copy, the
|
||||
diff still excludes that filename next time because it is attached to an
|
||||
archive, and nothing else in the flow cares. Returns True only on an actual
|
||||
delete or a 550 (already gone).
|
||||
"""
|
||||
if not verified:
|
||||
logger.warning(
|
||||
"[TIMELAPSE] Not deleting %s from printer %s: the download was never size-checked",
|
||||
remote_path,
|
||||
printer_name,
|
||||
)
|
||||
return False
|
||||
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
result = await delete_file_async(ip_address, access_code, remote_path, printer_model=printer_model)
|
||||
except Exception as e:
|
||||
result = DeleteResult.FAILED
|
||||
logger.warning("[TIMELAPSE] Delete attempt %d/3 raised for %s: %s", attempt, remote_path, e)
|
||||
|
||||
if result == DeleteResult.DELETED:
|
||||
logger.info("[TIMELAPSE] Deleted %s from printer %s after archiving", remote_path, printer_name)
|
||||
return True
|
||||
if result == DeleteResult.NOT_FOUND:
|
||||
# 550 never recovers by waiting — the printer already cleaned up.
|
||||
logger.debug("[TIMELAPSE] %s already gone from printer %s", remote_path, printer_name)
|
||||
return True
|
||||
if attempt < 3:
|
||||
await asyncio.sleep(2)
|
||||
|
||||
logger.warning(
|
||||
"[TIMELAPSE] Could not delete %s from printer %s (it stays on the card; the archive copy is unaffected)",
|
||||
remote_path,
|
||||
printer_name,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def get_storage_info_async(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@ Supports two camera protocols:
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -16,6 +17,8 @@ import uuid
|
|||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.logging_filters import redact_url_credentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# JPEG markers
|
||||
|
|
@ -32,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None
|
|||
# The cleanup task in routes/camera.py checks this set to avoid killing active captures.
|
||||
_active_capture_pids: set[int] = set()
|
||||
|
||||
# In-flight one-shot captures, keyed by printer IP (#2705).
|
||||
#
|
||||
# Bambu firmware allows exactly one camera connection, and the existing guards
|
||||
# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a
|
||||
# capturer from competing with the fan-out BROADCASTER. They do nothing for
|
||||
# capturer-vs-capturer with no viewer attached, where every consumer correctly
|
||||
# concludes it isn't competing with a viewer and then collides with the others.
|
||||
# Eight paths reach capture_camera_frame_bytes() independently — Obico polling,
|
||||
# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate
|
||||
# detection, the camera test and the diagnose tool — so the single-flight lives
|
||||
# at the bottom of the stack and needs no call-site changes.
|
||||
#
|
||||
# Keyed by IP rather than printer_id because IP is what the firmware's one-
|
||||
# connection limit applies to: two printer rows pointing at the same address
|
||||
# still share one camera. (This function never sees a printer_id anyway.) The
|
||||
# key deliberately excludes the timeout, or callers that disagree about it —
|
||||
# and they all do, from 10s to 30s — would never coalesce, which is exactly
|
||||
# the Obico-vs-snapshot pair from the report.
|
||||
_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {}
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | None:
|
||||
"""Find the ffmpeg executable path.
|
||||
|
|
@ -527,6 +550,38 @@ async def capture_camera_frame(
|
|||
return False
|
||||
|
||||
|
||||
def capture_in_flight(ip_address: str) -> bool:
|
||||
"""Return True iff a one-shot capture for this IP is running right now.
|
||||
|
||||
For callers that need to know whether they will JOIN someone else's
|
||||
capture rather than perform their own — currently only the diagnose tool,
|
||||
which reports on what it measured and so must not present a coalesced
|
||||
frame as proof that it opened its own connection (see camera_diagnose).
|
||||
|
||||
Ordinary consumers should ignore this: they want "a recent frame", and
|
||||
capture_camera_frame_bytes() already does the right thing for them.
|
||||
"""
|
||||
task = _inflight_captures.get(ip_address)
|
||||
return task is not None and not task.done()
|
||||
|
||||
|
||||
def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None:
|
||||
"""Done-callback: drop the finished task from the in-flight registry.
|
||||
|
||||
Guarded on identity so a slow task that finishes after a newer capture
|
||||
has registered can't evict its successor.
|
||||
|
||||
Also retrieves the exception, if any. The leader normally awaits the task
|
||||
and would surface it, but a leader whose own caller was cancelled leaves
|
||||
nobody to collect it — and an unretrieved task exception is logged by
|
||||
asyncio as a warning with a traceback at an arbitrary later point.
|
||||
"""
|
||||
if _inflight_captures.get(ip_address) is task:
|
||||
del _inflight_captures[ip_address]
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.debug("In-flight camera capture for %s ended in an exception", ip_address)
|
||||
|
||||
|
||||
async def capture_camera_frame_bytes(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
|
|
@ -535,18 +590,95 @@ async def capture_camera_frame_bytes(
|
|||
) -> bytes | None:
|
||||
"""Capture a single frame and return as JPEG bytes (no disk write).
|
||||
|
||||
Uses the same protocol selection as capture_camera_frame but returns
|
||||
bytes directly instead of writing to disk.
|
||||
Concurrent callers for the same printer share one capture (#2705): the
|
||||
first opens the connection, everyone arriving while it is in flight awaits
|
||||
the same result. Every consumer here wants "a recent frame" rather than
|
||||
"a frame captured at exactly my timestamp", so handing identical bytes to
|
||||
simultaneous callers is correct — and it is the only way to honour the
|
||||
firmware's one-connection limit without serialising captures behind a lock
|
||||
(which would just turn a collision into a queue).
|
||||
|
||||
This coalesces; it does not cache. A call that arrives after the previous
|
||||
capture finished always captures fresh. Two consumers of these frames —
|
||||
plate detection and the finish-photo path — decide things about a running
|
||||
print from them, and a stale frame there is worse than a slow one: the
|
||||
whole of #1397 was a finish photo taken seconds late showing the bed
|
||||
already lowered.
|
||||
|
||||
Args:
|
||||
ip_address: Printer IP address
|
||||
access_code: Printer access code
|
||||
model: Printer model (X1, H2D, P1, A1, etc.)
|
||||
timeout: Timeout in seconds for the capture operation
|
||||
timeout: Timeout in seconds for the capture operation. Applies to this
|
||||
caller's own wait, including when it joins another caller's
|
||||
capture — the call sites disagree about the value (10s for plate
|
||||
detection, 20s for Obico), and a follower must not silently
|
||||
inherit the leader's deadline in either direction.
|
||||
|
||||
Returns:
|
||||
JPEG bytes if capture was successful, None otherwise
|
||||
"""
|
||||
# A follower whose leader fails takes a turn of its own rather than
|
||||
# inheriting a failure it never had a chance to avoid — by then the leader
|
||||
# has finished, so there is no socket left to compete with. Bounded at two
|
||||
# rounds: if the capture we joined AND its replacement both failed, a third
|
||||
# connection won't help, and this caller has already spent its patience.
|
||||
for _ in range(2):
|
||||
leader = _inflight_captures.get(ip_address)
|
||||
if leader is None or leader.done():
|
||||
break
|
||||
try:
|
||||
frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
|
||||
except TimeoutError:
|
||||
# shield() keeps the capture running for whoever else is still
|
||||
# waiting on it — giving up is this caller's decision alone.
|
||||
logger.warning(
|
||||
"Gave up waiting %ss on the in-flight camera capture for %s",
|
||||
timeout,
|
||||
ip_address,
|
||||
)
|
||||
return None
|
||||
except asyncio.CancelledError:
|
||||
# Distinguish "the capture I joined was cancelled" from "I was
|
||||
# cancelled". Only the former is ours to recover from.
|
||||
if not leader.cancelled():
|
||||
raise
|
||||
logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address)
|
||||
continue
|
||||
if frame is not None:
|
||||
logger.info(
|
||||
"Reusing in-flight camera capture for %s: %s bytes (no second connection opened)",
|
||||
ip_address,
|
||||
len(frame),
|
||||
)
|
||||
return frame
|
||||
logger.info("In-flight camera capture for %s failed; capturing our own", ip_address)
|
||||
else:
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout))
|
||||
_inflight_captures[ip_address] = task
|
||||
task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address))
|
||||
# No wait_for here: this caller IS the capture, and the implementation
|
||||
# already enforces `timeout` internally where it can also kill the ffmpeg
|
||||
# process. A second deadline on top would abandon the subprocess instead.
|
||||
# shield() so that a cancelled leader (a client navigating away mid-
|
||||
# snapshot is routine) doesn't take the capture down with it — the
|
||||
# followers already waiting on it still get their frame.
|
||||
return await asyncio.shield(task)
|
||||
|
||||
|
||||
async def _capture_camera_frame_bytes_uncoalesced(
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
model: str | None,
|
||||
timeout: int = 15,
|
||||
) -> bytes | None:
|
||||
"""Open a connection and capture one frame. See capture_camera_frame_bytes.
|
||||
|
||||
Callers want that wrapper, not this: it opens a socket unconditionally,
|
||||
which is the collision #2705 is about.
|
||||
"""
|
||||
# Chamber image models: A1/P1 - returns bytes directly
|
||||
if is_chamber_image_model(model):
|
||||
logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model)
|
||||
|
|
@ -608,7 +740,8 @@ async def capture_camera_frame_bytes(
|
|||
logger.info("Successfully captured camera frame bytes: %s bytes", len(stdout))
|
||||
return stdout
|
||||
else:
|
||||
stderr_text = stderr.decode() if stderr else "Unknown error"
|
||||
# ffmpeg echoes the RTSP input URL, which carries the access code.
|
||||
stderr_text = redact_url_credentials(stderr.decode()) if stderr else "Unknown error"
|
||||
logger.error("ffmpeg frame bytes capture failed (code %s): %s", process.returncode, stderr_text[:200])
|
||||
return None
|
||||
|
||||
|
|
@ -703,12 +836,72 @@ async def extract_video_last_frame(video_path: Path, output_path: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def apply_camera_rotation(image_data: bytes, rotation: int, logger: logging.Logger) -> bytes:
|
||||
"""Apply a camera_rotation value (degrees clockwise) to a captured JPEG.
|
||||
|
||||
Shared by every capture path that saves a still image (notification
|
||||
snapshots, finish photos, layer-timelapse frames) - previously only
|
||||
wired into the notification-snapshot path, which left finish photos
|
||||
and timelapse videos upside-down whenever camera_rotation was set.
|
||||
|
||||
Returns *image_data* itself (identity, not a copy) when there is nothing
|
||||
to do or the rotate fails; callers that write to disk use that to skip a
|
||||
pointless rewrite.
|
||||
"""
|
||||
if not rotation:
|
||||
return image_data
|
||||
|
||||
try:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(BytesIO(image_data))
|
||||
# PIL rotate is counter-clockwise, so negate for clockwise rotation
|
||||
img = img.rotate(-rotation, expand=True)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
rotated = buf.getvalue()
|
||||
# Debug, not info: layer-timelapse calls this once per layer, so a tall
|
||||
# print would otherwise put hundreds of lines in the log for something
|
||||
# the surrounding capture already reports at debug level.
|
||||
logger.debug("Applied %d° camera rotation: %s → %s bytes", rotation, len(image_data), len(rotated))
|
||||
return rotated
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply camera rotation: %s", e)
|
||||
return image_data
|
||||
|
||||
|
||||
async def apply_camera_rotation_to_file(path: Path, rotation: int, logger: logging.Logger) -> None:
|
||||
"""Rotate a JPEG that has already been written to disk, in place.
|
||||
|
||||
Two finish-photo sources never hold the frame as bytes - ``ffmpeg`` writes
|
||||
the file for them, and they return only a filename - so they can't use
|
||||
``apply_camera_rotation`` directly. Best-effort: any failure leaves the
|
||||
unrotated file in place, which is what the caller had before.
|
||||
"""
|
||||
if not rotation:
|
||||
return
|
||||
|
||||
try:
|
||||
data = await asyncio.to_thread(path.read_bytes)
|
||||
rotated = await asyncio.to_thread(apply_camera_rotation, data, rotation, logger)
|
||||
if rotated is data:
|
||||
# Nothing was done (the rotate failed and returned its input) -
|
||||
# rewriting the same bytes would only risk truncating a good file.
|
||||
return
|
||||
await asyncio.to_thread(path.write_bytes, rotated)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to rotate %s in place: %s", path.name, e)
|
||||
|
||||
|
||||
async def capture_finish_photo(
|
||||
printer_id: int,
|
||||
ip_address: str,
|
||||
access_code: str,
|
||||
model: str | None,
|
||||
archive_dir: Path,
|
||||
rotation: int = 0,
|
||||
) -> str | None:
|
||||
"""Capture a finish photo and save it to the archive's photos folder.
|
||||
|
||||
|
|
@ -718,6 +911,9 @@ async def capture_finish_photo(
|
|||
access_code: Printer access code
|
||||
model: Printer model
|
||||
archive_dir: Directory of the archive (where the 3MF is stored)
|
||||
rotation: Printer's configured camera_rotation (degrees clockwise).
|
||||
ffmpeg writes the file directly here, so the rotation is applied
|
||||
to it afterwards rather than to bytes in hand.
|
||||
|
||||
Returns:
|
||||
Filename of the captured photo, or None if capture failed
|
||||
|
|
@ -742,6 +938,7 @@ async def capture_finish_photo(
|
|||
)
|
||||
|
||||
if success:
|
||||
await apply_camera_rotation_to_file(output_path, rotation, logger)
|
||||
logger.info("Finish photo saved: %s", filename)
|
||||
return filename
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True
|
|||
AND a buffered frame is fresh (last 10 s), we short-circuit the test
|
||||
with ``live_stream_active`` and report success — the user is
|
||||
literally watching the camera right now, no test needed.
|
||||
|
||||
The related case is another one-shot capture (Obico polling, the cam
|
||||
wall) being in flight when the user hits Diagnose. There the capture
|
||||
layer coalesces for us (#2705) and no competing socket is opened, but
|
||||
the frame we get back was someone else's — so ``first_frame`` still
|
||||
passes and carries a ``coalesced_capture`` code, because a diagnostic
|
||||
that reports a connection it didn't open is worse than a slow one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -46,6 +53,7 @@ from dataclasses import dataclass, field
|
|||
|
||||
from backend.app.services.camera import (
|
||||
capture_camera_frame_bytes,
|
||||
capture_in_flight,
|
||||
get_camera_port,
|
||||
is_chamber_image_model,
|
||||
)
|
||||
|
|
@ -69,8 +77,10 @@ class CameraDiagnoseStage:
|
|||
name: str # "tcp_reachable" | "first_frame" | "live_stream_active"
|
||||
status: str # "ok" | "failed" | "skipped"
|
||||
duration_ms: int = 0
|
||||
# Optional machine-readable code for failures so the frontend can
|
||||
# render a stage-specific hint without parsing free-text errors.
|
||||
# Optional machine-readable code so the frontend can render a stage-
|
||||
# specific hint without parsing free-text errors. Usually a failure
|
||||
# reason; "coalesced_capture" qualifies a PASS whose frame came from a
|
||||
# capture already in flight, so duration_ms isn't a connection time.
|
||||
code: str | None = None
|
||||
|
||||
|
||||
|
|
@ -166,6 +176,15 @@ async def _check_first_frame(
|
|||
"""Stage 2 — capture one frame end-to-end. Combines auth + protocol
|
||||
handshake + first keyframe; either it works or it doesn't."""
|
||||
started = time.monotonic()
|
||||
# A capture already running for this printer (an Obico poll, the cam wall)
|
||||
# means capture_camera_frame_bytes will hand us THAT capture's frame rather
|
||||
# than opening its own connection (#2705). Good for the printer, but this
|
||||
# stage exists to report what it measured: the frame would be real evidence
|
||||
# the camera works, while duration_ms would be mostly time spent queueing,
|
||||
# and a pass would be claimed for a connection we never opened. So the
|
||||
# stage says so, the same way the live-stream shortcut above declares
|
||||
# itself instead of quietly passing.
|
||||
coalesced = capture_in_flight(ip_address)
|
||||
try:
|
||||
jpeg = await capture_camera_frame_bytes(
|
||||
ip_address=ip_address,
|
||||
|
|
@ -190,7 +209,11 @@ async def _check_first_frame(
|
|||
name="first_frame",
|
||||
status="ok",
|
||||
duration_ms=int((time.monotonic() - started) * 1000),
|
||||
code="coalesced_capture" if coalesced else None,
|
||||
)
|
||||
# No annotation on the failure path: a follower whose leader fails goes on
|
||||
# to capture on its own, so a None here means this stage did get its own
|
||||
# attempt (or watched two consecutive captures fail — same verdict).
|
||||
return CameraDiagnoseStage(
|
||||
name="first_frame",
|
||||
status="failed",
|
||||
|
|
|
|||
193
backend/app/services/design_settings.py
Normal file
193
backend/app/services/design_settings.py
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
"""Carry a 3MF designer's own process tweaks across a re-slice (#2622).
|
||||
|
||||
A MakerWorld model is often published with deliberate deviations from the stock
|
||||
Bambu process preset — 5 walls, 100% infill, a 0.1mm first layer. Re-slicing that
|
||||
file for a different printer used to drop every one of them: ``--load-settings``
|
||||
is authoritative, so the picked process preset wins over the 3MF's embedded
|
||||
``Metadata/project_settings.config``.
|
||||
|
||||
We do not have to *compute* what the designer changed. BambuStudio already did,
|
||||
and wrote the answer into the file:
|
||||
|
||||
different_settings_to_system = [
|
||||
"enable_support;inner_wall_speed;sparse_infill_density;...", # [0] process
|
||||
"filament_change_length;filament_prime_volume", # [1..N] filaments
|
||||
"machine_start_gcode;bed_custom_model;...", # [-1] printer
|
||||
]
|
||||
|
||||
The array is ``1 + len(filament_settings_id) + 1`` long — verified against real
|
||||
files at 2, 3 and 4 filament slots. Index 0 is exactly the set of process keys
|
||||
that differ from the system preset, which is the reporter's step 1 for free: no
|
||||
baseline resolution, no shipping BBL profiles into Bambuddy, and no new endpoint
|
||||
on the slicer sidecar (which exposes bundled presets by name only, with no way to
|
||||
flatten one).
|
||||
|
||||
Delivery is the mechanism ``_patch_process_support_settings`` already proved in
|
||||
#1881: write the values into the process JSON that goes out as ``--load-settings``.
|
||||
For a "standard" preset pick that JSON is a ``{inherits: …}`` stub, so the keys we
|
||||
write are the *child* in the inherits chain and win over the flattened parent.
|
||||
|
||||
Not every key is safe to carry, though. Real files put ``inner_wall_speed``,
|
||||
``outer_wall_speed`` and ``prime_tower_max_speed`` in that list — values tuned for
|
||||
the designer's machine that can be plain wrong, or out of range, on the target.
|
||||
Those are classified :data:`PRINTER_COUPLED` and offered unticked; the caller
|
||||
decides. Nothing is applied that the caller did not ask for by name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PROJECT_SETTINGS = "Metadata/project_settings.config"
|
||||
|
||||
|
||||
class DesignOverride(NamedTuple):
|
||||
"""One process setting the designer changed away from the system preset."""
|
||||
|
||||
key: str
|
||||
value: Any
|
||||
printer_coupled: bool
|
||||
|
||||
|
||||
# Process keys whose sane value depends on the machine, not on the design intent.
|
||||
# The designer picked these for *their* printer's kinematics, chamber and hotend;
|
||||
# carrying them onto another model risks a slice that is merely slower/uglier —
|
||||
# or a hard range-validation reject from the CLI, which is how the very first
|
||||
# slicer spike died. Offered, but never pre-selected.
|
||||
#
|
||||
# Matching is by exact key OR by suffix/substring rule below, because Bambu's
|
||||
# process schema has dozens of per-feature speed keys and an exhaustive literal
|
||||
# list would rot on every slicer release.
|
||||
_PRINTER_COUPLED_EXACT: frozenset[str] = frozenset(
|
||||
{
|
||||
"default_acceleration",
|
||||
"independent_support_layer_height",
|
||||
"precise_z_height",
|
||||
"travel_acceleration",
|
||||
"enable_wrapping_detection",
|
||||
}
|
||||
)
|
||||
|
||||
# Substring rules for the families that are always machine-coupled. Kept
|
||||
# deliberately narrow: "speed", "acceleration"/"accel" and "jerk" are the
|
||||
# kinematic families, "fan"/"temperature" follow the hotend and chamber, and
|
||||
# "prime_tower" follows the target's toolchange hardware.
|
||||
_PRINTER_COUPLED_SUBSTRINGS: tuple[str, ...] = (
|
||||
# Prime-tower geometry (and whether there is one at all) follows the target's
|
||||
# extruder count and bed, not the design — a real file carries five of these.
|
||||
"prime_tower",
|
||||
"_speed",
|
||||
"speed_",
|
||||
"acceleration",
|
||||
"_accel",
|
||||
"jerk",
|
||||
"fan_speed",
|
||||
"_temperature",
|
||||
"temperature_",
|
||||
)
|
||||
|
||||
|
||||
def is_printer_coupled(key: str) -> bool:
|
||||
"""Whether carrying this process key across printer models is risky."""
|
||||
if key in _PRINTER_COUPLED_EXACT:
|
||||
return True
|
||||
lowered = key.lower()
|
||||
return any(token in lowered for token in _PRINTER_COUPLED_SUBSTRINGS)
|
||||
|
||||
|
||||
def _split_changed_keys(entry: Any) -> list[str]:
|
||||
"""Parse one ``different_settings_to_system`` entry into its key names."""
|
||||
if not isinstance(entry, str):
|
||||
return []
|
||||
return [part.strip() for part in entry.split(";") if part.strip()]
|
||||
|
||||
|
||||
def extract_design_process_overrides(zip_bytes: bytes) -> list[DesignOverride]:
|
||||
"""Process settings the 3MF's designer changed away from the system preset.
|
||||
|
||||
Returns an empty list for anything that is not a BambuStudio-style 3MF
|
||||
carrying both ``project_settings.config`` and a well-formed
|
||||
``different_settings_to_system`` — including OrcaSlicer files and older
|
||||
exports that predate the field. Callers treat empty as "nothing to offer",
|
||||
which is the pre-feature behaviour.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(zip_bytes), "r") as zf:
|
||||
if _PROJECT_SETTINGS not in zf.namelist():
|
||||
return []
|
||||
config = json.loads(zf.read(_PROJECT_SETTINGS).decode("utf-8"))
|
||||
except (zipfile.BadZipFile, json.JSONDecodeError, UnicodeDecodeError, OSError, KeyError):
|
||||
return []
|
||||
return overrides_from_config(config)
|
||||
|
||||
|
||||
def overrides_from_config(config: Any) -> list[DesignOverride]:
|
||||
"""``extract_design_process_overrides`` on an already-parsed config dict."""
|
||||
if not isinstance(config, dict):
|
||||
return []
|
||||
|
||||
changed = config.get("different_settings_to_system")
|
||||
if not isinstance(changed, list) or not changed:
|
||||
return []
|
||||
|
||||
# Sanity-check the layout before trusting index 0. The array should be
|
||||
# [process, *filaments, printer]; a file whose length disagrees with its own
|
||||
# filament count is one we do not understand, and guessing there could carry
|
||||
# printer G-code into the process slot.
|
||||
filaments = config.get("filament_settings_id")
|
||||
if isinstance(filaments, list) and len(changed) != len(filaments) + 2:
|
||||
logger.debug(
|
||||
"3MF different_settings_to_system has %d entries for %d filaments "
|
||||
"(expected %d) — skipping design-settings carry-over",
|
||||
len(changed),
|
||||
len(filaments),
|
||||
len(filaments) + 2,
|
||||
)
|
||||
return []
|
||||
|
||||
overrides: list[DesignOverride] = []
|
||||
# Index 0 is the process slot — see the layout in the module docstring. The
|
||||
# length check above is what earns the right to index it blindly.
|
||||
for key in _split_changed_keys(changed[0]):
|
||||
if key not in config:
|
||||
# Listed as changed but absent from the flattened config — nothing
|
||||
# to carry. Seen with keys the slicer renamed between versions.
|
||||
continue
|
||||
overrides.append(DesignOverride(key=key, value=config[key], printer_coupled=is_printer_coupled(key)))
|
||||
|
||||
overrides.sort(key=lambda o: o.key)
|
||||
return overrides
|
||||
|
||||
|
||||
def apply_design_overrides(process_json: str, overrides: list[DesignOverride], selected_keys: list[str]) -> str:
|
||||
"""Write the selected designer values into the outgoing process JSON.
|
||||
|
||||
``selected_keys`` is authoritative — a key the caller did not name is not
|
||||
applied even when it is present in ``overrides``. Returns ``process_json``
|
||||
unchanged when nothing is selected or the JSON is unparseable, so a bad
|
||||
input degrades to a plain profile slice rather than failing it.
|
||||
"""
|
||||
if not selected_keys or not overrides:
|
||||
return process_json
|
||||
|
||||
wanted = set(selected_keys)
|
||||
by_key = {o.key: o.value for o in overrides if o.key in wanted}
|
||||
if not by_key:
|
||||
return process_json
|
||||
|
||||
try:
|
||||
process_cfg = json.loads(process_json)
|
||||
except json.JSONDecodeError:
|
||||
return process_json
|
||||
if not isinstance(process_cfg, dict):
|
||||
return process_json
|
||||
|
||||
process_cfg.update(by_key)
|
||||
logger.info("Carrying %d design setting(s) onto the picked process preset: %s", len(by_key), sorted(by_key))
|
||||
return json.dumps(process_cfg)
|
||||
|
|
@ -99,9 +99,15 @@ class ExportService:
|
|||
Returns:
|
||||
Tuple of (file_bytes, filename, content_type)
|
||||
"""
|
||||
# Build query
|
||||
# Build query. Soft-deleted archives (#1343) are excluded: this export
|
||||
# is the list the user is looking at, saved to a file, and that list
|
||||
# hides them — an export that silently contains rows the UI says are
|
||||
# gone is worse than useless for reconciling anything (#2731).
|
||||
query = (
|
||||
select(PrintArchive).options(selectinload(PrintArchive.project)).order_by(PrintArchive.created_at.desc())
|
||||
select(PrintArchive)
|
||||
.options(selectinload(PrintArchive.project))
|
||||
.where(PrintArchive.deleted_at.is_(None))
|
||||
.order_by(PrintArchive.created_at.desc())
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
|
|
|
|||
|
|
@ -8,15 +8,18 @@ to ensure they are well-formed before use.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
from backend.app.core.logging_filters import redact_url_credentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -173,6 +176,70 @@ def get_ffmpeg_path() -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
# In-flight one-shot captures, keyed by (url, camera_type, snapshot_url) —
|
||||
# the tuple that actually identifies the physical resource being contended
|
||||
# (#2707 comment thread, following #2705's shape for the built-in path).
|
||||
#
|
||||
# V4L2 USB devices allow exactly one open handle, and is_stream_active() /
|
||||
# try_get_active_buffered_frame() (#2707) only stop a one-shot capturer from
|
||||
# competing with the fan-out live view. They do nothing for capturer-vs-
|
||||
# capturer with no viewer attached, where every consumer correctly concludes
|
||||
# it isn't competing with a viewer and then collides with the others -
|
||||
# exactly the #2705 report, just for this module's callers instead of
|
||||
# capture_camera_frame_bytes()'s (Obico polling, the in-print frame bank,
|
||||
# the finish-photo moment, plate detection, and the notification snapshot
|
||||
# all reach capture_frame() independently).
|
||||
#
|
||||
# snapshot_url is part of the key (not just url/camera_type) because it
|
||||
# routes to a completely different endpoint (#1177) - two printers that
|
||||
# share a camera_url but differ only in snapshot_url must not coalesce.
|
||||
_inflight_captures: dict[tuple[str, str, str | None], asyncio.Task[bytes | None]] = {}
|
||||
|
||||
|
||||
def capture_in_flight(url: str, camera_type: str, snapshot_url: str | None = None) -> bool:
|
||||
"""Return True iff a one-shot capture for this key is running right now.
|
||||
|
||||
Mirrors camera.py's capture_in_flight() for the built-in path - for a
|
||||
caller that needs to know it will JOIN someone else's capture rather
|
||||
than open its own connection. Ordinary consumers should ignore this:
|
||||
they want "a recent frame", and capture_frame() already does the right
|
||||
thing for them.
|
||||
"""
|
||||
task = _inflight_captures.get((url, camera_type, snapshot_url))
|
||||
return task is not None and not task.done()
|
||||
|
||||
|
||||
def _discard_inflight_capture(key: tuple[str, str, str | None], task: asyncio.Task) -> None:
|
||||
"""Done-callback: drop the finished task from the in-flight registry.
|
||||
|
||||
Guarded on identity so a slow task that finishes after a newer capture
|
||||
has registered for the same key can't evict its successor.
|
||||
|
||||
Also retrieves the exception, if any: the leader normally awaits the
|
||||
task and would surface it, but a leader whose own caller was cancelled
|
||||
leaves nobody to collect it, and an unretrieved task exception is
|
||||
logged by asyncio as a warning with a traceback at an arbitrary later
|
||||
point otherwise.
|
||||
"""
|
||||
if _inflight_captures.get(key) is task:
|
||||
del _inflight_captures[key]
|
||||
if not task.cancelled() and task.exception() is not None:
|
||||
logger.debug("In-flight external-camera capture for %s ended in an exception", _log_key(key))
|
||||
|
||||
|
||||
def _log_key(key: tuple[str, str, str | None]) -> str:
|
||||
"""Render an in-flight key for a log line, with credentials redacted.
|
||||
|
||||
Unlike camera.py's coalescing — which is keyed by IP address and so has
|
||||
nothing to hide — these keys carry the camera URL, and an RTSP camera URL
|
||||
routinely embeds ``user:pass@``. Redact before truncating: slicing first
|
||||
can cut the URL short of the ``@`` the pattern anchors on and leave the
|
||||
password in the log, which is why every other URL log in this module does
|
||||
it in this order.
|
||||
"""
|
||||
return redact_url_credentials(key[0])[:50] if key[0] else "None"
|
||||
|
||||
|
||||
async def capture_frame(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
|
|
@ -184,7 +251,10 @@ async def capture_frame(
|
|||
Args:
|
||||
url: Live-stream URL (MJPEG stream, RTSP URL, HTTP snapshot URL, or USB device path).
|
||||
camera_type: "mjpeg", "rtsp", "snapshot", or "usb".
|
||||
timeout: Connection timeout in seconds.
|
||||
timeout: Connection timeout in seconds. Applies to this caller's own
|
||||
wait, including when it joins another caller's capture - call
|
||||
sites disagree about the value, and a follower must not silently
|
||||
inherit the leader's deadline in either direction.
|
||||
snapshot_url: Optional override for single-frame capture. When set, fetched
|
||||
via plain HTTP GET regardless of `camera_type`. Bypasses MJPEG warm-up
|
||||
handling on sources that expose a dedicated frame endpoint (e.g. go2rtc's
|
||||
|
|
@ -193,21 +263,120 @@ async def capture_frame(
|
|||
|
||||
Returns:
|
||||
JPEG bytes or None on failure
|
||||
|
||||
Concurrent callers for the same (url, camera_type, snapshot_url) share
|
||||
one capture (#2705-shape fix, filed for the external-camera path as a
|
||||
follow-up on #2707): the first opens the connection, everyone arriving
|
||||
while it's in flight awaits the same result. This coalesces; it does
|
||||
not cache - a call that arrives after the previous capture finished
|
||||
always captures fresh, since plate detection and the finish-photo path
|
||||
judge a running print from these frames and a stale one there is worse
|
||||
than a slow one (#1397).
|
||||
"""
|
||||
if snapshot_url:
|
||||
logger.debug("capture_frame using snapshot override url=%s...", snapshot_url[:50])
|
||||
return await _capture_snapshot(snapshot_url, timeout)
|
||||
logger.debug("capture_frame called: type=%s, url=%s...", camera_type, url[:50] if url else "None")
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
elif camera_type == "rtsp":
|
||||
return await _capture_rtsp_frame(url, timeout)
|
||||
elif camera_type == "snapshot":
|
||||
return await _capture_snapshot(url, timeout)
|
||||
elif camera_type == "usb":
|
||||
return await _capture_usb_frame(url, timeout)
|
||||
key = (url, camera_type, snapshot_url)
|
||||
|
||||
# A follower whose leader fails takes a turn of its own rather than
|
||||
# inheriting a failure it never had a chance to avoid - by then the
|
||||
# leader has finished, so there's no connection left to compete with.
|
||||
# Bounded at two rounds: if the capture we joined AND its replacement
|
||||
# both failed, a third attempt won't help, and this caller has already
|
||||
# spent its patience.
|
||||
for _ in range(2):
|
||||
leader = _inflight_captures.get(key)
|
||||
if leader is None or leader.done():
|
||||
break
|
||||
try:
|
||||
frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout)
|
||||
except TimeoutError:
|
||||
# shield() keeps the capture running for whoever else is still
|
||||
# waiting on it - giving up is this caller's decision alone.
|
||||
logger.warning(
|
||||
"Gave up waiting %ss on the in-flight external-camera capture for %s", timeout, _log_key(key)
|
||||
)
|
||||
return None
|
||||
except asyncio.CancelledError:
|
||||
# Distinguish "the capture I joined was cancelled" from "I was
|
||||
# cancelled". Only the former is ours to recover from.
|
||||
if not leader.cancelled():
|
||||
raise
|
||||
logger.info("In-flight external-camera capture for %s was cancelled; capturing our own", _log_key(key))
|
||||
continue
|
||||
if frame is not None:
|
||||
logger.debug(
|
||||
"Reusing in-flight external-camera capture for %s: %d bytes (no second connection opened)",
|
||||
_log_key(key),
|
||||
len(frame),
|
||||
)
|
||||
return frame
|
||||
logger.debug("In-flight external-camera capture for %s failed; capturing our own", _log_key(key))
|
||||
else:
|
||||
logger.warning("Unknown camera type: %s", camera_type)
|
||||
return None
|
||||
|
||||
task = asyncio.create_task(_capture_frame_uncoalesced(url, camera_type, timeout, snapshot_url))
|
||||
_inflight_captures[key] = task
|
||||
task.add_done_callback(functools.partial(_discard_inflight_capture, key))
|
||||
# No wait_for here: this caller IS the capture, and each dispatched
|
||||
# _capture_* function already enforces `timeout` internally, where it
|
||||
# can also kill the ffmpeg process - a second deadline on top would
|
||||
# abandon the subprocess instead of killing it. shield() so a cancelled
|
||||
# leader (a client navigating away mid-request is routine) doesn't take
|
||||
# the capture down with it - followers already waiting on it still get
|
||||
# their frame.
|
||||
return await asyncio.shield(task)
|
||||
|
||||
|
||||
async def _capture_frame_uncoalesced(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
timeout: int,
|
||||
snapshot_url: str | None,
|
||||
) -> bytes | None:
|
||||
"""Open a connection and capture one frame. See capture_frame().
|
||||
|
||||
Callers want that wrapper, not this: it opens a connection
|
||||
unconditionally, which is the collision #2705/#2707 are about.
|
||||
|
||||
Failure is reported as ``None``, never as an exception. That is load-
|
||||
bearing now that captures are shared: the coalescing wrapper hands one
|
||||
task's outcome to every caller waiting on it, and it can only give a
|
||||
follower its own turn for an outcome it can recognise. An exception
|
||||
escaping here would instead propagate to every follower at once —
|
||||
turning one caller's failure into N — and none of them would retry.
|
||||
The per-type helpers below each catch what they expect and return None,
|
||||
but they catch narrowly (``aiohttp.ClientError``/``OSError``/timeouts),
|
||||
so this is the structural guarantee rather than one contingent on their
|
||||
coverage. Mirrors ``_capture_camera_frame_bytes_uncoalesced`` in
|
||||
camera.py, which ends in the same blanket catch for the same reason.
|
||||
"""
|
||||
try:
|
||||
if snapshot_url:
|
||||
# Redact before truncating — slicing first can cut the URL short of the
|
||||
# ``@`` the pattern anchors on and leave the password in the log.
|
||||
logger.debug("capture_frame using snapshot override url=%s...", redact_url_credentials(snapshot_url)[:50])
|
||||
return await _capture_snapshot(snapshot_url, timeout)
|
||||
logger.debug(
|
||||
"capture_frame called: type=%s, url=%s...",
|
||||
camera_type,
|
||||
redact_url_credentials(url)[:50] if url else "None",
|
||||
)
|
||||
if camera_type == "mjpeg":
|
||||
return await _capture_mjpeg_frame(url, timeout)
|
||||
elif camera_type == "rtsp":
|
||||
return await _capture_rtsp_frame(url, timeout)
|
||||
elif camera_type == "snapshot":
|
||||
return await _capture_snapshot(url, timeout)
|
||||
elif camera_type == "usb":
|
||||
return await _capture_usb_frame(url, timeout)
|
||||
else:
|
||||
logger.warning("Unknown camera type: %s", camera_type)
|
||||
return None
|
||||
except asyncio.CancelledError:
|
||||
# Cancellation is not a capture failure and must stay distinguishable:
|
||||
# the wrapper checks ``leader.cancelled()`` to decide whether a
|
||||
# follower may take its own turn.
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("External camera capture failed for %s", redact_url_credentials(url)[:50] if url else "None")
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -311,7 +480,7 @@ async def _capture_mjpeg_frame(url: str, timeout: int) -> bytes | None:
|
|||
"""
|
||||
safe_url = _sanitize_camera_url(url, ("http", "https"))
|
||||
if not safe_url:
|
||||
logger.error("Invalid MJPEG URL format: %s...", url[:50])
|
||||
logger.error("Invalid MJPEG URL format: %s...", redact_url_credentials(url)[:50])
|
||||
return None
|
||||
|
||||
jpeg_start = b"\xff\xd8"
|
||||
|
|
@ -438,7 +607,8 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
|
|||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
logger.error("ffmpeg RTSP capture failed: %s", stderr.decode()[:200])
|
||||
# ffmpeg echoes the RTSP input URL, which carries the camera password.
|
||||
logger.error("ffmpeg RTSP capture failed: %s", redact_url_credentials(stderr.decode())[:200])
|
||||
return None
|
||||
|
||||
if not stdout or len(stdout) < 100:
|
||||
|
|
@ -504,7 +674,7 @@ async def _capture_snapshot(url: str, timeout: int) -> bytes | None:
|
|||
# Sanitize URL - returns reconstructed URL from validated components
|
||||
safe_url = _sanitize_camera_url(url, ("http", "https"))
|
||||
if not safe_url:
|
||||
logger.error("Invalid snapshot URL format: %s...", url[:50])
|
||||
logger.error("Invalid snapshot URL format: %s...", redact_url_credentials(url)[:50])
|
||||
return None
|
||||
|
||||
try:
|
||||
|
|
@ -557,12 +727,26 @@ async def test_connection(url: str, camera_type: str) -> dict:
|
|||
"""Test camera connection.
|
||||
|
||||
Returns:
|
||||
Dict with {success: bool, error?: str, resolution?: str}
|
||||
Dict with {success: bool, error?: str, resolution?: str, coalesced: bool}
|
||||
|
||||
``coalesced`` is True when the frame came from a capture that was already
|
||||
running rather than from a connection this test opened. Captures are shared
|
||||
(see ``capture_frame``), so a test that lands while Obico is polling — or
|
||||
while any other one-shot consumer is mid-capture — gets that frame back and
|
||||
would otherwise report a healthy connection it never made, which is the one
|
||||
answer a *connection test* must not give silently. Forcing an uncoalesced
|
||||
capture here would be worse: it would open the second handle to a
|
||||
single-reader device that this whole mechanism exists to prevent. So the
|
||||
test still shares, and says so. Mirrors the ``coalesced_capture`` code the
|
||||
built-in diagnostic reports for the same situation (camera_diagnose.py).
|
||||
"""
|
||||
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, url[:50])
|
||||
logger.info("Testing camera connection: type=%s, url=%s...", camera_type, redact_url_credentials(url)[:50])
|
||||
# Sampled before the call, while it can still distinguish "someone else is
|
||||
# mid-capture" from "I am the one capturing".
|
||||
coalesced = capture_in_flight(url, camera_type)
|
||||
try:
|
||||
frame = await capture_frame(url, camera_type, timeout=10)
|
||||
logger.info("Capture result: %s bytes", len(frame) if frame else 0)
|
||||
logger.info("Capture result: %s bytes%s", len(frame) if frame else 0, " (coalesced)" if coalesced else "")
|
||||
|
||||
if frame:
|
||||
# Try to get resolution from JPEG header
|
||||
|
|
@ -581,24 +765,52 @@ async def test_connection(url: str, camera_type: str) -> dict:
|
|||
except (IndexError, ValueError):
|
||||
pass # Resolution detection is optional; fall back to default
|
||||
|
||||
return {"success": True, "resolution": resolution}
|
||||
return {"success": True, "resolution": resolution, "coalesced": coalesced}
|
||||
else:
|
||||
return {"success": False, "error": "Failed to capture frame from camera"}
|
||||
return {"success": False, "error": "Failed to capture frame from camera", "coalesced": coalesced}
|
||||
|
||||
except Exception as e:
|
||||
# Sanitize error message - don't expose internal details
|
||||
error_type = type(e).__name__
|
||||
logger.error("Camera connection test failed: %s", e)
|
||||
return {"success": False, "error": f"Connection failed: {error_type}"}
|
||||
return {"success": False, "error": f"Connection failed: {error_type}", "coalesced": coalesced}
|
||||
|
||||
|
||||
async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> AsyncGenerator[bytes, None]:
|
||||
async def generate_mjpeg_stream(
|
||||
url: str,
|
||||
camera_type: str,
|
||||
fps: int = 10,
|
||||
*,
|
||||
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
||||
on_frame: Callable[[bytes], None] | None = None,
|
||||
stop_event: asyncio.Event | None = None,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Generator yielding MJPEG frames for streaming.
|
||||
|
||||
Args:
|
||||
url: Camera URL or USB device path
|
||||
camera_type: "mjpeg", "rtsp", "snapshot", or "usb"
|
||||
fps: Target frames per second
|
||||
on_process: Called with the spawned ffmpeg process for the ``usb`` and
|
||||
``rtsp`` paths so the route layer can register it into the shared
|
||||
stream registries — that's what lets ``/camera/stop`` and the orphan
|
||||
janitor find and kill a leaked ffmpeg that's holding a USB device
|
||||
open (#2675). Without it the process is reachable only from this
|
||||
generator's own ``finally``, which an abrupt client disconnect can
|
||||
skip (same cancellation-timing class as #776).
|
||||
on_frame: Called with each RAW frame, before it is wrapped for the wire,
|
||||
so the route layer can publish it as the printer's buffered frame
|
||||
(#2707). It has to be a callback: what this generator yields is
|
||||
multipart-wrapped, so a consumer of the stream cannot recover the
|
||||
JPEG, and until now nothing populated the buffer for external
|
||||
cameras at all — leaving every one-shot consumer (layer timelapse,
|
||||
finish photo, Obico, plate check) with nothing to reuse and no
|
||||
option but to open a competing handle on a single-reader device.
|
||||
Exceptions are logged and swallowed: buffering must never be able
|
||||
to break the live stream.
|
||||
stop_event: When set, the reconnect loops stop retrying — so an explicit
|
||||
stop (which kills the current ffmpeg) doesn't immediately respawn a
|
||||
new process and reacquire the device.
|
||||
|
||||
Yields:
|
||||
MJPEG frame data with HTTP multipart boundaries
|
||||
|
|
@ -606,6 +818,15 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|||
frame_interval = 1.0 / max(fps, 1)
|
||||
last_frame_time = 0.0
|
||||
|
||||
def _publish(frame: bytes) -> bytes:
|
||||
"""Hand the raw frame to on_frame, then format it for the wire."""
|
||||
if on_frame is not None:
|
||||
try:
|
||||
on_frame(frame)
|
||||
except Exception:
|
||||
logger.exception("on_frame callback raised")
|
||||
return _format_mjpeg_frame(frame)
|
||||
|
||||
if camera_type == "mjpeg":
|
||||
# Proxy MJPEG stream directly, with reconnect on timeout
|
||||
max_retries = 3
|
||||
|
|
@ -616,8 +837,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|||
current_time = asyncio.get_event_loop().time()
|
||||
if current_time - last_frame_time >= frame_interval:
|
||||
last_frame_time = current_time
|
||||
yield _format_mjpeg_frame(frame)
|
||||
if not frame_yielded or attempt == max_retries:
|
||||
yield _publish(frame)
|
||||
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
|
||||
break
|
||||
logger.warning(
|
||||
"External MJPEG stream ended, reconnecting (attempt %d/%d)...",
|
||||
|
|
@ -631,10 +852,10 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|||
max_retries = 3
|
||||
for attempt in range(max_retries + 1):
|
||||
frame_yielded = False
|
||||
async for frame in _stream_rtsp(url, fps):
|
||||
async for frame in _stream_rtsp(url, fps, on_process=on_process):
|
||||
frame_yielded = True
|
||||
yield _format_mjpeg_frame(frame)
|
||||
if not frame_yielded or attempt == max_retries:
|
||||
yield _publish(frame)
|
||||
if not frame_yielded or attempt == max_retries or (stop_event is not None and stop_event.is_set()):
|
||||
break
|
||||
logger.warning(
|
||||
"External RTSP stream ended, reconnecting (attempt %d/%d)...",
|
||||
|
|
@ -645,8 +866,8 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|||
|
||||
elif camera_type == "usb":
|
||||
# Use ffmpeg to stream from USB camera
|
||||
async for frame in _stream_usb(url, fps):
|
||||
yield _format_mjpeg_frame(frame)
|
||||
async for frame in _stream_usb(url, fps, on_process=on_process):
|
||||
yield _publish(frame)
|
||||
|
||||
elif camera_type == "snapshot":
|
||||
# Poll snapshot URL at interval
|
||||
|
|
@ -654,7 +875,7 @@ async def generate_mjpeg_stream(url: str, camera_type: str, fps: int = 10) -> As
|
|||
try:
|
||||
frame = await _capture_snapshot(url, timeout=10)
|
||||
if frame:
|
||||
yield _format_mjpeg_frame(frame)
|
||||
yield _publish(frame)
|
||||
await asyncio.sleep(frame_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
|
@ -683,7 +904,7 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
|
|||
# Sanitize URL - returns reconstructed URL from validated components
|
||||
safe_url = _sanitize_camera_url(url, ("http", "https"))
|
||||
if not safe_url:
|
||||
logger.error("Invalid MJPEG stream URL: %s...", url[:50])
|
||||
logger.error("Invalid MJPEG stream URL: %s...", redact_url_credentials(url)[:50])
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -724,7 +945,12 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
|
|||
logger.error("MJPEG stream error: %s", e)
|
||||
|
||||
|
||||
async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
||||
async def _stream_rtsp(
|
||||
url: str,
|
||||
fps: int,
|
||||
*,
|
||||
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Stream frames from RTSP URL via ffmpeg.
|
||||
|
||||
For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
|
||||
|
|
@ -805,12 +1031,18 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# Register immediately — before the startup probe below — so a process
|
||||
# that hangs on connect (rather than exiting) is still reachable by the
|
||||
# stop endpoint / orphan janitor (#2675).
|
||||
if on_process is not None:
|
||||
on_process(process)
|
||||
|
||||
# Brief check for immediate startup failures
|
||||
await asyncio.sleep(0.1)
|
||||
if process.returncode is not None:
|
||||
stderr = await process.stderr.read()
|
||||
logger.error("ffmpeg RTSP stream failed immediately: %s", stderr.decode()[:300])
|
||||
# ffmpeg echoes the RTSP input URL, which carries the camera password.
|
||||
logger.error("ffmpeg RTSP stream failed immediately: %s", redact_url_credentials(stderr.decode())[:300])
|
||||
return
|
||||
|
||||
buffer = b""
|
||||
|
|
@ -865,7 +1097,12 @@ async def _stream_rtsp(url: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|||
await proxy_server.wait_closed()
|
||||
|
||||
|
||||
async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
|
||||
async def _stream_usb(
|
||||
device: str,
|
||||
fps: int,
|
||||
*,
|
||||
on_process: Callable[[asyncio.subprocess.Process], None] | None = None,
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Stream frames from USB camera via ffmpeg."""
|
||||
ffmpeg = get_ffmpeg_path()
|
||||
if not ffmpeg:
|
||||
|
|
@ -907,6 +1144,12 @@ async def _stream_usb(device: str, fps: int) -> AsyncGenerator[bytes, None]:
|
|||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# Register immediately — before the startup probe below — so a process
|
||||
# that hangs in open()/ioctl on a still-locked device (rather than
|
||||
# exiting with a "busy" error) is still reachable by the stop endpoint /
|
||||
# orphan janitor (#2675).
|
||||
if on_process is not None:
|
||||
on_process(process)
|
||||
|
||||
# Give ffmpeg a moment to start and check for immediate failures
|
||||
await asyncio.sleep(0.5)
|
||||
|
|
|
|||
|
|
@ -55,8 +55,15 @@ class FailureAnalysisService:
|
|||
if project_id:
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
# Soft-deleted archives (#1343) keep their project_id, so without
|
||||
# this the failure rate for a project still counts prints the user
|
||||
# deleted from it — and disagrees with the project's own numbers,
|
||||
# which now exclude them (#2731).
|
||||
project_archive_ids = await self.db.execute(
|
||||
select(PrintArchive.id).where(PrintArchive.project_id == project_id)
|
||||
select(PrintArchive.id).where(
|
||||
PrintArchive.project_id == project_id,
|
||||
PrintArchive.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
archive_ids = [row[0] for row in project_archive_ids.fetchall()]
|
||||
if archive_ids:
|
||||
|
|
|
|||
|
|
@ -63,14 +63,21 @@ class GiteaBackend(GitHubBackend):
|
|||
return tree_node.get("sha")
|
||||
return None
|
||||
|
||||
# Gitea/Forgejo can be hosted under a URL path prefix (ROOT_URL like
|
||||
# https://host/gitea), so the repo lives at /<prefix...>/<owner>/<repo>
|
||||
# rather than at the host root (#2642). Capture the scheme+host+prefix as
|
||||
# one group and the final two path segments as owner/repo; the lazy prefix
|
||||
# group is empty for a root-hosted instance. One shared pattern keeps
|
||||
# parse_repo_url() and get_api_base() from drifting.
|
||||
_HTTPS_REPO_RE = re.compile(
|
||||
r"(https?://[\w.\-]+(?::\d+)?(?:/[\w.\-]+)*?)/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$"
|
||||
)
|
||||
|
||||
def parse_repo_url(self, url: str) -> tuple[str, str]:
|
||||
"""Return (owner, repo) — accepts both https:// and http:// for self-hosted instances."""
|
||||
if not url or len(url) > 500:
|
||||
raise ValueError("Invalid Git URL: URL too long or empty")
|
||||
match = re.match(
|
||||
r"https?://[\w.\-]+(:\d+)?/([\w.\-]{1,100})/([\w.\-]{1,100})(?:\.git)?/?$",
|
||||
url,
|
||||
)
|
||||
match = self._HTTPS_REPO_RE.match(url)
|
||||
if match:
|
||||
return match.group(2), match.group(3).removesuffix(".git")
|
||||
match = re.match(
|
||||
|
|
@ -82,8 +89,8 @@ class GiteaBackend(GitHubBackend):
|
|||
raise ValueError(f"Cannot parse repository URL: {url}")
|
||||
|
||||
def get_api_base(self, repo_url: str) -> str:
|
||||
"""Derive API base from the repository URL's scheme and host."""
|
||||
match = re.match(r"(https?://[\w.\-]+(:\d+)?)/", repo_url)
|
||||
"""Derive API base from the repository URL's scheme, host and any path prefix."""
|
||||
match = self._HTTPS_REPO_RE.match(repo_url)
|
||||
if match:
|
||||
return f"{match.group(1)}/api/v1"
|
||||
raise ValueError(f"Cannot derive API base from URL: {repo_url}")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import logging
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy import desc, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.database import async_session
|
||||
|
|
@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
|
|||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.spool import Spool
|
||||
from backend.app.models.spool_usage_history import SpoolUsageHistory
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.git_providers.factory import get_provider_backend
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bambu's listing endpoint is keyed by preset type and calls process presets
|
||||
# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
|
||||
# divergence here silently drops a whole preset type from every backup.
|
||||
_BAMBU_PRESET_TYPES = {
|
||||
"filament": "filament",
|
||||
"printer": "printer",
|
||||
"print": "process",
|
||||
}
|
||||
|
||||
|
||||
def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
|
||||
"""One Bambu preset as stored in the backup: metadata plus the payload.
|
||||
|
||||
``base_id`` and ``setting`` are the two fields ``BambuCloudService.
|
||||
create_setting`` needs, so a restore can rebuild the preset rather than
|
||||
just list it.
|
||||
|
||||
``user_id`` from the listing is deliberately dropped. It identifies the
|
||||
account and adds nothing to a rebuild, and backup repositories can be
|
||||
public.
|
||||
"""
|
||||
return {
|
||||
"setting_id": str(setting_id),
|
||||
"name": detail.get("name") or entry.get("name") or "Unknown",
|
||||
"type": our_type,
|
||||
"version": detail.get("version") or entry.get("version"),
|
||||
"updated_time": entry.get("updated_time"),
|
||||
"base_id": detail.get("base_id"),
|
||||
"filament_id": detail.get("filament_id"),
|
||||
"setting": detail.get("setting") or {},
|
||||
}
|
||||
|
||||
|
||||
def _orca_profile_record(entry: dict) -> dict:
|
||||
"""One Orca profile as stored in the backup.
|
||||
|
||||
``content`` is kept whole rather than picked apart: it is the profile, the
|
||||
sync API hands it over inline, and Orca owns its shape. Narrowing it here
|
||||
would mean guessing which keys a future restore needs.
|
||||
"""
|
||||
return {
|
||||
"id": str(entry.get("id")) if entry.get("id") is not None else None,
|
||||
"name": entry.get("name"),
|
||||
"updated_time": entry.get("updated_time"),
|
||||
"created_time": entry.get("created_time"),
|
||||
"content": entry.get("content"),
|
||||
}
|
||||
|
||||
|
||||
# Schedule intervals in seconds
|
||||
SCHEDULE_INTERVALS = {
|
||||
"hourly": 3600,
|
||||
|
|
@ -279,11 +329,13 @@ class GitHubBackupService:
|
|||
{
|
||||
"backup_metadata.json": {...},
|
||||
"kprofiles/{serial}/{nozzle}.json": {...},
|
||||
"cloud_profiles/filament.json": [...],
|
||||
"cloud_profiles/printer.json": [...],
|
||||
"cloud_profiles/process.json": [...],
|
||||
"cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
|
||||
"cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
|
||||
"settings/app_settings.json": {...},
|
||||
}
|
||||
|
||||
``{account}`` is ``global`` when auth is disabled, otherwise
|
||||
``user-{id}`` — one directory per connected cloud account (#2717).
|
||||
"""
|
||||
files: dict[str, dict | list] = {}
|
||||
|
||||
|
|
@ -306,10 +358,20 @@ class GitHubBackupService:
|
|||
self._backup_progress = "Collecting K-profiles from printers..."
|
||||
await self._collect_kprofiles(db, files)
|
||||
|
||||
# Collect cloud profiles
|
||||
# Collect cloud profiles. `contents.cloud_profiles` is corrected below
|
||||
# from what was configured to what was actually written — it claimed
|
||||
# `true` on every backup, including the ones that collected nothing
|
||||
# (#2717), which is exactly the signal a restore needs to be able to
|
||||
# trust.
|
||||
if config.backup_cloud_profiles:
|
||||
self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
|
||||
await self._collect_cloud_profiles(db, files)
|
||||
self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
|
||||
cloud_summary = await self._collect_cloud_profiles(db, files)
|
||||
collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
|
||||
metadata["contents"]["cloud_profiles"] = collected
|
||||
if collected:
|
||||
# Per-cloud, per-account counts, so a restore can tell an empty
|
||||
# account from one that failed to collect.
|
||||
metadata["cloud_profiles"] = cloud_summary
|
||||
|
||||
# Collect app settings
|
||||
if config.backup_settings:
|
||||
|
|
@ -374,69 +436,284 @@ class GitHubBackupService:
|
|||
if printer_profiles:
|
||||
logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
|
||||
|
||||
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
|
||||
"""Collect Bambu Cloud profiles if authenticated."""
|
||||
# Backup runs without a user context, so fall back to the auth-disabled
|
||||
# Settings storage. ``build_authenticated_cloud`` honours the stored
|
||||
# region so China-region tokens are validated against api.bambulab.cn.
|
||||
async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
|
||||
"""Collect slicer presets from every connected cloud account.
|
||||
|
||||
Two clouds, and on an auth-enabled install any number of accounts in
|
||||
each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
|
||||
tokens on ``User.orca_cloud_token``, falling back to the global
|
||||
``Settings`` table only when auth is disabled. The previous version
|
||||
asked for the auth-disabled store unconditionally, so it collected
|
||||
nothing at all on any install with auth on (#2717).
|
||||
|
||||
Layout is one directory per cloud per account, both clouds grouped the
|
||||
same way so a restore reads them identically::
|
||||
|
||||
cloud_profiles/bambu/user-3/{filament,printer,process}.json
|
||||
cloud_profiles/orca/user-3/{filament,printer,process}.json
|
||||
|
||||
Accounts are keyed by Bambuddy user id (``global`` when auth is off),
|
||||
never by email — a backup repository can be public.
|
||||
|
||||
Returns a per-cloud summary for ``backup_metadata.json`` so the
|
||||
metadata records what was actually collected rather than what was
|
||||
merely enabled.
|
||||
"""
|
||||
summary: dict = {"bambu": {}, "orca": {}}
|
||||
|
||||
bambu_accounts, orca_accounts = await self.cloud_accounts(db)
|
||||
if not bambu_accounts and not orca_accounts:
|
||||
# Enabled but nothing to collect. Deliberately a warning: the INFO
|
||||
# line this replaces read as a successful collection of nothing,
|
||||
# which is how #2717 went unnoticed through every backup.
|
||||
logger.warning(
|
||||
"Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
|
||||
"account is connected — nothing to collect."
|
||||
)
|
||||
return summary
|
||||
|
||||
for account_key, user in bambu_accounts:
|
||||
try:
|
||||
counts = await self._collect_bambu_profiles(db, files, account_key, user)
|
||||
except Exception:
|
||||
logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
|
||||
continue
|
||||
if counts:
|
||||
summary["bambu"][account_key] = counts
|
||||
|
||||
for account_key, user in orca_accounts:
|
||||
try:
|
||||
counts = await self._collect_orca_profiles(db, files, account_key, user)
|
||||
except Exception:
|
||||
logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
|
||||
continue
|
||||
if counts:
|
||||
summary["orca"][account_key] = counts
|
||||
|
||||
if not summary["bambu"] and not summary["orca"]:
|
||||
logger.warning(
|
||||
"Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
|
||||
"but no presets were collected — see the per-account warnings above.",
|
||||
len(bambu_accounts),
|
||||
len(orca_accounts),
|
||||
)
|
||||
else:
|
||||
logger.info("Collected cloud profiles: %s", summary)
|
||||
return summary
|
||||
|
||||
async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
|
||||
"""Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
|
||||
|
||||
With auth enabled every user holds their own credentials, so a backup
|
||||
that only looked at the global store saw none of them. With auth
|
||||
disabled there is a single global row and no ``User`` at all, which is
|
||||
what ``user=None`` means to both clouds' credential loaders.
|
||||
|
||||
Both stores are read regardless: a ``Settings`` row survives enabling
|
||||
auth later, and dropping it silently would lose that account's presets.
|
||||
"""
|
||||
from backend.app.api.routes.cloud import get_stored_token
|
||||
from backend.app.api.routes.orca_cloud import _load_credentials
|
||||
|
||||
bambu: list = []
|
||||
orca: list = []
|
||||
|
||||
global_token, _email, _region = await get_stored_token(db, None)
|
||||
if global_token:
|
||||
bambu.append(("global", None))
|
||||
global_orca = await _load_credentials(db, None)
|
||||
if global_orca.token:
|
||||
orca.append(("global", None))
|
||||
|
||||
result = await db.execute(
|
||||
select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
|
||||
)
|
||||
for user in result.scalars().all():
|
||||
if user.cloud_token:
|
||||
bambu.append((f"user-{user.id}", user))
|
||||
if user.orca_cloud_token:
|
||||
orca.append((f"user-{user.id}", user))
|
||||
|
||||
return bambu, orca
|
||||
|
||||
async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
|
||||
"""Collect one Bambu Cloud account's custom presets, with their payloads.
|
||||
|
||||
The listing endpoint is keyed by preset type, each holding ``private``
|
||||
and ``public`` lists — there is no flat ``setting`` array, and the
|
||||
entries carry no ``type`` of their own, which is why the type comes
|
||||
from the outer key here exactly as it does in ``routes/cloud.py``.
|
||||
Bambu calls process presets ``print``.
|
||||
|
||||
``public`` is skipped: those are Bambu's own bundled catalogue, the
|
||||
same hundreds of entries for every user, re-downloadable at any time
|
||||
and not recreatable under your account anyway. Backing them up would
|
||||
churn the repository on every run for nothing.
|
||||
|
||||
Each private preset then costs one ``get_setting_detail`` call, because
|
||||
the listing carries only metadata. Without ``base_id`` and ``setting``
|
||||
the backup is a list of names, not something a restore can rebuild
|
||||
from. Bounded by the number of *custom* presets, and the backup already
|
||||
makes a round-trip per printer for K-profiles.
|
||||
"""
|
||||
from backend.app.api.routes.cloud import build_authenticated_cloud
|
||||
|
||||
cloud = await build_authenticated_cloud(db, user=None)
|
||||
cloud = await build_authenticated_cloud(db, user=user)
|
||||
if cloud is None or not cloud.is_authenticated:
|
||||
if cloud is not None:
|
||||
await cloud.close()
|
||||
logger.info("Cloud not authenticated, skipping cloud profiles")
|
||||
return
|
||||
logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
|
||||
return {}
|
||||
|
||||
counts: dict = {}
|
||||
try:
|
||||
settings = await cloud.get_slicer_settings()
|
||||
if not settings:
|
||||
return
|
||||
if not isinstance(settings, dict) or not settings:
|
||||
logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
|
||||
return {}
|
||||
|
||||
# Separate by type
|
||||
filament_settings = []
|
||||
printer_settings = []
|
||||
process_settings = []
|
||||
failed = 0
|
||||
for api_key, our_type in _BAMBU_PRESET_TYPES.items():
|
||||
type_data = settings.get(api_key)
|
||||
if not isinstance(type_data, dict):
|
||||
continue
|
||||
private = type_data.get("private")
|
||||
if not isinstance(private, list) or not private:
|
||||
continue
|
||||
|
||||
for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
|
||||
setting_type = setting.get("type", "")
|
||||
if setting_type == "filament":
|
||||
filament_settings.append(setting)
|
||||
elif setting_type == "printer":
|
||||
printer_settings.append(setting)
|
||||
elif setting_type == "process":
|
||||
process_settings.append(setting)
|
||||
profiles = []
|
||||
for entry in private:
|
||||
setting_id = entry.get("setting_id") or entry.get("id")
|
||||
if not setting_id:
|
||||
continue
|
||||
try:
|
||||
detail = await cloud.get_setting_detail(str(setting_id))
|
||||
except Exception as e:
|
||||
# One unreadable preset must not cost the rest of the
|
||||
# account, but it must not vanish quietly either.
|
||||
failed += 1
|
||||
logger.warning(
|
||||
"Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
|
||||
setting_id,
|
||||
entry.get("name", "unnamed"),
|
||||
account_key,
|
||||
e,
|
||||
)
|
||||
continue
|
||||
profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
|
||||
|
||||
if filament_settings:
|
||||
files["cloud_profiles/filament.json"] = {
|
||||
"version": "1.0",
|
||||
"profiles": filament_settings,
|
||||
}
|
||||
if profiles:
|
||||
files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
|
||||
"version": "2.0",
|
||||
"cloud": "bambu",
|
||||
"type": our_type,
|
||||
"profiles": profiles,
|
||||
}
|
||||
counts[our_type] = len(profiles)
|
||||
|
||||
if printer_settings:
|
||||
files["cloud_profiles/printer.json"] = {
|
||||
"version": "1.0",
|
||||
"profiles": printer_settings,
|
||||
}
|
||||
|
||||
if process_settings:
|
||||
files["cloud_profiles/process.json"] = {
|
||||
"version": "1.0",
|
||||
"profiles": process_settings,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Collected cloud profiles: %d filament, %d printer, %d process",
|
||||
len(filament_settings),
|
||||
len(printer_settings),
|
||||
len(process_settings),
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.warning("Failed to collect cloud profiles", exc_info=True)
|
||||
if failed:
|
||||
counts["failed"] = failed
|
||||
return counts
|
||||
finally:
|
||||
await cloud.close()
|
||||
|
||||
async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
|
||||
"""Collect one Orca Cloud account's profiles, grouped the same three ways.
|
||||
|
||||
Cheaper than Bambu: the sync-pull listing already carries each
|
||||
profile's full ``content``, so there is no per-profile fetch.
|
||||
|
||||
The type lives at ``content.type`` and is mapped through the same
|
||||
``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
|
||||
exactly as the UI does. Where that route *drops* a profile whose type
|
||||
it can't map, this writes it to ``other.json`` instead — a backup that
|
||||
silently omits a profile because Orca added a type is the same class of
|
||||
bug as #2717 itself.
|
||||
|
||||
Uses the route layer's ``_build_authenticated_service`` rather than
|
||||
re-implementing the refresh: the Orca refresh token is single-use and
|
||||
rotating, and that helper already persists the new pair atomically
|
||||
before returning.
|
||||
|
||||
Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
|
||||
account instead of disconnecting it. A backup is an observer; it should
|
||||
not change anyone's sign-in state on a schedule, least of all on a
|
||||
rejection reason Orca does not disambiguate. The next time the user
|
||||
opens the Orca Profiles page that route clears the dead pairing anyway,
|
||||
with the user present to pair again.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from backend.app.api.routes.orca_cloud import (
|
||||
_ORCA_TYPE_TO_BAMBU,
|
||||
_build_authenticated_service,
|
||||
)
|
||||
|
||||
try:
|
||||
svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
|
||||
except HTTPException as e:
|
||||
# Either way the stored credentials are untouched and this account
|
||||
# is skipped, not disconnected — but the two need different advice.
|
||||
# A rejected refresh will not fix itself and needs the user to pair
|
||||
# again; an unreachable Orca is very likely gone by the next run.
|
||||
if e.status_code == 401:
|
||||
logger.warning(
|
||||
"Orca Cloud rejected the stored session for %s, so its profiles are not in this "
|
||||
"backup. Later runs will skip it too until the account is paired again under "
|
||||
"Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
|
||||
"cleared. Cause: %s",
|
||||
account_key,
|
||||
e.detail,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Orca Cloud unreachable for %s, skipping its profiles this run: %s",
|
||||
account_key,
|
||||
e.detail,
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
|
||||
return {}
|
||||
|
||||
counts: dict = {}
|
||||
try:
|
||||
raw_profiles = await svc.list_profiles()
|
||||
grouped: dict[str, list] = {}
|
||||
unknown_types: dict[str, int] = {}
|
||||
|
||||
for entry in raw_profiles:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
content = entry.get("content")
|
||||
raw_type = content.get("type") if isinstance(content, dict) else None
|
||||
our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
|
||||
if our_type is None:
|
||||
unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
|
||||
unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
|
||||
)
|
||||
our_type = "other"
|
||||
grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
|
||||
|
||||
for our_type, profiles in grouped.items():
|
||||
files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
|
||||
"version": "2.0",
|
||||
"cloud": "orca",
|
||||
"type": our_type,
|
||||
"profiles": profiles,
|
||||
}
|
||||
counts[our_type] = len(profiles)
|
||||
|
||||
if unknown_types:
|
||||
logger.warning(
|
||||
"Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
|
||||
"backed up to other.json rather than dropped.",
|
||||
sum(unknown_types.values()),
|
||||
account_key,
|
||||
unknown_types,
|
||||
)
|
||||
return counts
|
||||
finally:
|
||||
await svc.close()
|
||||
|
||||
async def _collect_settings(self, db: AsyncSession, files: dict):
|
||||
"""Collect app settings."""
|
||||
result = await db.execute(select(Settings))
|
||||
|
|
|
|||
|
|
@ -187,17 +187,40 @@ class HomeAssistantService:
|
|||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> str | None:
|
||||
"""Validate HA URL scheme and block dangerous destinations."""
|
||||
"""Normalise a caller-supplied HA URL, or return None if it is unsafe.
|
||||
|
||||
The stored ``ha_url`` setting is already validated at the schema layer
|
||||
(``LAN_SERVICE_URL_SETTINGS`` in schemas/settings.py), but
|
||||
``test_connection`` takes its URL straight from the request body, so
|
||||
the same policy has to be applied here.
|
||||
|
||||
Delegates to ``_url_safety.assert_safe_lan_service_url`` rather than
|
||||
the string blocklist this replaces. That blocklist only knew three
|
||||
literal hostnames plus a ``169.254.`` prefix and never parsed the
|
||||
hostname as an IP, so it let through the Alibaba (100.100.100.200)
|
||||
and AWS-IPv6 (fd00:ec2::254) metadata endpoints, numeric-encoded
|
||||
loopback, multicast, and IPv4-mapped IPv6 encodings of the IMDS
|
||||
address it did know about.
|
||||
|
||||
Loopback and RFC-1918 remain permitted — Home Assistant is a
|
||||
LAN-resident service by design, and the shared guard is documented
|
||||
that way.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
assert_safe_lan_service_url(url, label="Home Assistant URL")
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
# Guard passed, so the scheme is http/https and a hostname is present;
|
||||
# re-parse only to drop query/fragment and normalise the authority.
|
||||
parsed = urlparse(url)
|
||||
if not parsed.hostname:
|
||||
return None
|
||||
blocked = ("169.254.169.254", "metadata.google.internal", "0.0.0.0") # nosec B104
|
||||
if parsed.hostname.lower() in blocked or (parsed.hostname or "").startswith("169.254."):
|
||||
return None
|
||||
return f"{parsed.scheme}://{parsed.hostname}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
|
||||
# urlparse strips the brackets off an IPv6 literal, so they have to go
|
||||
# back on or the rebuilt URL is unparseable ("http://fd00::1:8123").
|
||||
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
|
||||
return f"{parsed.scheme.lower()}://{host}" + (f":{parsed.port}" if parsed.port else "") + (parsed.path or "")
|
||||
|
||||
async def test_connection(self, url: str, token: str) -> dict:
|
||||
"""Test connection to Home Assistant.
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ Captures a frame on each layer change and stitches them into a video on print co
|
|||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.services.camera import apply_camera_rotation
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -18,6 +20,15 @@ logger = logging.getLogger(__name__)
|
|||
# Active timelapse sessions: {printer_id: TimelapseSession}
|
||||
_active_sessions: dict[int, "TimelapseSession"] = {}
|
||||
|
||||
# Sessions whose frames are being stitched right now: {printer_id: session_id}.
|
||||
# on_print_complete removes the session from _active_sessions *before* handing
|
||||
# frames_dir to ffmpeg, so for the length of a stitch (up to 300s) nothing in
|
||||
# _active_sessions marks that directory as in use. Without this second registry
|
||||
# the only thing standing between an in-progress stitch and
|
||||
# cleanup_orphaned_timelapse_sessions() is the age margin — whose default is
|
||||
# exactly the stitch timeout, so there is no headroom at all.
|
||||
_finalizing_sessions: dict[int, str] = {}
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> str | None:
|
||||
"""Get the path to ffmpeg executable."""
|
||||
|
|
@ -41,6 +52,7 @@ class TimelapseSession:
|
|||
camera_url: str
|
||||
camera_type: str
|
||||
snapshot_url: str | None = None # Optional single-frame override; #1177
|
||||
rotation: int = 0 # Printer's configured camera_rotation, degrees clockwise
|
||||
last_layer: int = -1
|
||||
frame_count: int = 0
|
||||
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
||||
|
|
@ -67,8 +79,29 @@ class TimelapseSession:
|
|||
self.last_layer = layer_num
|
||||
|
||||
try:
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
|
||||
# Reuse the live view's frame instead of opening a second handle on
|
||||
# a single-reader device (#2707). Unguarded, a print watched from
|
||||
# start to finish recorded zero successful layer captures, and the
|
||||
# stitched video came out empty or badly truncated.
|
||||
from backend.app.api.routes.camera import live_frame_for_capture
|
||||
|
||||
defer, buffered = live_frame_for_capture(self.printer_id)
|
||||
if defer:
|
||||
if not buffered:
|
||||
# Viewer attached but nothing buffered yet: skip this layer
|
||||
# rather than compete and kick them off (#1348).
|
||||
logger.debug(
|
||||
"Skipping layer %s for printer %s: viewer attached, no buffered frame yet",
|
||||
layer_num,
|
||||
self.printer_id,
|
||||
)
|
||||
return False
|
||||
frame_data = buffered
|
||||
else:
|
||||
frame_data = await capture_frame(self.camera_url, self.camera_type, snapshot_url=self.snapshot_url)
|
||||
if frame_data:
|
||||
if self.rotation:
|
||||
frame_data = await asyncio.to_thread(apply_camera_rotation, frame_data, self.rotation, logger)
|
||||
frame_path = self.frames_dir / f"layer_{layer_num:05d}.jpg"
|
||||
await asyncio.to_thread(frame_path.write_bytes, frame_data)
|
||||
self.frame_count += 1
|
||||
|
|
@ -187,6 +220,7 @@ def start_session(
|
|||
url: str,
|
||||
cam_type: str,
|
||||
snapshot_url: str | None = None,
|
||||
rotation: int = 0,
|
||||
) -> TimelapseSession:
|
||||
"""Start new timelapse session for a printer.
|
||||
|
||||
|
|
@ -197,6 +231,8 @@ def start_session(
|
|||
cam_type: Camera type ("mjpeg", "rtsp", "snapshot")
|
||||
snapshot_url: Optional single-frame URL override; when set, layer captures
|
||||
fetch from it directly instead of opening the live stream. #1177.
|
||||
rotation: Printer's configured camera_rotation (degrees clockwise),
|
||||
applied to every captured frame before it's saved.
|
||||
|
||||
Returns:
|
||||
The new TimelapseSession
|
||||
|
|
@ -210,6 +246,7 @@ def start_session(
|
|||
camera_url=url,
|
||||
camera_type=cam_type,
|
||||
snapshot_url=snapshot_url,
|
||||
rotation=rotation,
|
||||
)
|
||||
_active_sessions[printer_id] = session
|
||||
logger.info("Started timelapse session for printer %s", printer_id)
|
||||
|
|
@ -254,6 +291,12 @@ async def on_print_complete(printer_id: int) -> Path | None:
|
|||
# Create output path in parent of frames dir
|
||||
output_path = session.frames_dir.parent / f"timelapse_{session.session_id}.mp4"
|
||||
|
||||
# The session is already out of _active_sessions, so mark it finalizing for
|
||||
# the length of the stitch — otherwise a sweep running now sees a frames
|
||||
# directory that matches no session and whose mtime is the last layer's
|
||||
# write, which on a tall print's final layer is easily older than the age
|
||||
# margin, and deletes ffmpeg's input from under it.
|
||||
_finalizing_sessions[printer_id] = session.session_id
|
||||
try:
|
||||
success = await session.stitch(output_path)
|
||||
if success:
|
||||
|
|
@ -267,6 +310,8 @@ async def on_print_complete(printer_id: int) -> Path | None:
|
|||
logger.error("Timelapse completion failed: %s", e)
|
||||
session.cleanup()
|
||||
return None
|
||||
finally:
|
||||
_finalizing_sessions.pop(printer_id, None)
|
||||
|
||||
|
||||
def cancel_session(printer_id: int):
|
||||
|
|
@ -284,3 +329,92 @@ def cancel_session(printer_id: int):
|
|||
def get_active_sessions() -> dict[int, TimelapseSession]:
|
||||
"""Get all active timelapse sessions."""
|
||||
return _active_sessions.copy()
|
||||
|
||||
|
||||
def cleanup_orphaned_timelapse_sessions(min_age_seconds: float = 300) -> int:
|
||||
"""Remove timelapse_frames/<printer_id>/* left behind by a crash or
|
||||
restart that happened while a session was active.
|
||||
|
||||
_active_sessions is in-memory only, so a process restart loses track of
|
||||
any in-flight session without ever calling cancel_session()/cleanup() -
|
||||
the frames directory (and, if stitching had already produced output
|
||||
before the restart, a stray `timelapse_<session_id>.mp4`) are then
|
||||
orphaned on disk with nothing else to reap them (unlike the ffmpeg
|
||||
orphan janitor in routes/camera.py, there was no equivalent here).
|
||||
|
||||
Safe to call once at startup: normal operation always cleans up via
|
||||
on_print_complete/cancel_session, so anything found here predates this
|
||||
process - and a restart-recovered print doesn't get a new timelapse
|
||||
session either (`_maybe_start_layer_timelapse` is only wired into fresh
|
||||
PRINT_START events, see #1353), so an orphaned directory can never be
|
||||
resumed.
|
||||
|
||||
Also safe to call mid-run, which needs all three guards rather than the
|
||||
age margin alone:
|
||||
|
||||
* `_active_sessions` covers a session that is still capturing.
|
||||
* `_finalizing_sessions` covers the stitch window. on_print_complete drops
|
||||
the session from `_active_sessions` before handing frames_dir to ffmpeg,
|
||||
so without this the directory matches no session for up to 300s while
|
||||
being actively read.
|
||||
* `min_age_seconds` covers the remaining gap - a session in the middle of
|
||||
being created, and the stitched `.mp4` between ffmpeg finishing it and
|
||||
the caller attaching and unlinking it. Both are freshly written, so the
|
||||
margin has real headroom there; it did NOT have any for the stitch
|
||||
window, whose length is bounded by the same 300s.
|
||||
|
||||
Returns the number of orphaned directories/files removed.
|
||||
"""
|
||||
base_dir = settings.base_dir / "timelapse_frames"
|
||||
if not base_dir.exists():
|
||||
return 0
|
||||
|
||||
now = time.time()
|
||||
removed = 0
|
||||
for printer_dir in base_dir.iterdir():
|
||||
if not printer_dir.is_dir():
|
||||
continue
|
||||
try:
|
||||
printer_id = int(printer_dir.name)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
active_session = _active_sessions.get(printer_id)
|
||||
in_use_session_ids = {
|
||||
active_session.session_id if active_session else None,
|
||||
_finalizing_sessions.get(printer_id),
|
||||
} - {None}
|
||||
|
||||
for entry in printer_dir.iterdir():
|
||||
# Frame dirs are named "<session_id>/"; stitched-but-not-yet-
|
||||
# attached output files are "timelapse_<session_id>.mp4" (see
|
||||
# on_print_complete's output_path). Anything else under here was
|
||||
# not written by this module, so leave it alone rather than
|
||||
# deleting a file on the strength of its age.
|
||||
if entry.is_dir():
|
||||
entry_session_id = entry.name
|
||||
elif entry.name.startswith("timelapse_") and entry.name.endswith(".mp4"):
|
||||
entry_session_id = entry.name[len("timelapse_") : -len(".mp4")]
|
||||
else:
|
||||
continue
|
||||
if entry_session_id in in_use_session_ids:
|
||||
continue
|
||||
try:
|
||||
if now - entry.stat().st_mtime < min_age_seconds:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
# No ignore_errors: it would swallow a failed removal while the
|
||||
# count and the log line below still claimed success, and that
|
||||
# log is the only evidence an operator has of what was deleted.
|
||||
if entry.is_dir():
|
||||
shutil.rmtree(entry)
|
||||
else:
|
||||
entry.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
logger.info("Removed orphaned timelapse artifact: %s", entry)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove orphaned timelapse artifact %s: %s", entry, e)
|
||||
|
||||
return removed
|
||||
|
|
|
|||
|
|
@ -256,10 +256,14 @@ def authenticate_ldap_user(config: LDAPConfig, username: str, password: str) ->
|
|||
return None
|
||||
|
||||
info = _extract_user_info(service_conn, config, user_entry, username)
|
||||
# Don't log the raw DN — its leaf CN is the user's real name (PII, #2681).
|
||||
# The username + group count is enough to confirm a successful auth; the
|
||||
# support-bundle sanitizer also redacts any DN that slips through (e.g. an
|
||||
# ldap3 exception string), but keeping it out of the log at the source is
|
||||
# the primary hygiene per the "no private data in logs" rule.
|
||||
logger.info(
|
||||
"LDAP authentication successful for user: %s (DN: %s, groups: %d)",
|
||||
"LDAP authentication successful for user: %s (groups: %d)",
|
||||
info.username,
|
||||
user_dn,
|
||||
len(info.groups),
|
||||
)
|
||||
return info
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from sqlalchemy import select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.user import User
|
||||
|
|
@ -25,6 +26,21 @@ logger = logging.getLogger(__name__)
|
|||
# parse it out; the log-health scanner does not.
|
||||
LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
|
||||
|
||||
# LDAP Distinguished Names carry PII — the leaf ``CN=`` is the user's real name
|
||||
# (#2681). Match a run of at least two ``attr=value`` RDN components joined by
|
||||
# commas, where ``attr`` is a known LDAP attribute type. Requiring two components
|
||||
# keeps this from clobbering an incidental ``key=value`` in an unrelated log line,
|
||||
# while still catching DNs wherever they surface — the deliberate "auth successful"
|
||||
# line, ldap3 exception strings, and group DNs alike. Bias is intentionally toward
|
||||
# redaction: over-redacting a rare debug line to ``[DN]`` is a safe failure; leaking
|
||||
# a name is not.
|
||||
# The value char class excludes `<>;+` — RFC 4514 requires those escaped inside a
|
||||
# DN value, so an unescaped one marks the end of the DN, not part of it. That stops
|
||||
# the final (comma-unbounded) component from greedily swallowing trailing log text
|
||||
# such as ``… -> GroupName``.
|
||||
_LDAP_RDN = r"(?:CN|OU|DC|UID|O|L|ST|C|SN|GN|DN|E|MAIL|STREET|GIVENNAME|SURNAME)=[^,\n<>;+]+"
|
||||
_LDAP_DN_PATTERN = re.compile(rf"(?i)\b{_LDAP_RDN}(?:\s*,\s*{_LDAP_RDN})+")
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
"""A single parsed log entry."""
|
||||
|
|
@ -153,12 +169,18 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
|
|||
continue # Skip very short strings to prevent over-redaction
|
||||
content = re.sub(re.escape(value), label, content)
|
||||
|
||||
# Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host)
|
||||
content = re.sub(r"((?:https?|rtsps?)://)[^/:@\s]+:[^/@\s]+@", r"\1[CREDENTIALS]@", content)
|
||||
# Replace credentials in URLs (e.g. http://user:pass@host, rtsps://bblp:code@host).
|
||||
# Shares its pattern with the log-pipeline redaction in ``core.logging_filters`` so
|
||||
# the two can't drift; the bundle drops the username too, where the live log keeps
|
||||
# it for diagnosis.
|
||||
content = URL_CREDENTIALS_PATTERN.sub(r"\g<scheme>[CREDENTIALS]@", content)
|
||||
|
||||
# Replace email addresses
|
||||
content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
|
||||
|
||||
# Replace LDAP Distinguished Names (#2681) — PII on par with email.
|
||||
content = _LDAP_DN_PATTERN.sub("[DN]", content)
|
||||
|
||||
# Replace Bambu Lab printer serial numbers (format: 00M/01D/01S/01P/03W + alphanumeric, 12-16 chars total)
|
||||
content = re.sub(r"\b0[0-3][A-Z0-9][A-Z0-9]{9,13}\b", "[SERIAL]", content, flags=re.IGNORECASE)
|
||||
|
||||
|
|
|
|||
|
|
@ -240,7 +240,14 @@ class MQTTRelayService:
|
|||
# Printer Events
|
||||
# =========================================================================
|
||||
|
||||
async def on_printer_status(self, printer_id: int, state: Any, printer_name: str, printer_serial: str):
|
||||
async def on_printer_status(
|
||||
self,
|
||||
printer_id: int,
|
||||
state: Any,
|
||||
printer_name: str,
|
||||
printer_serial: str,
|
||||
awaiting_plate_clear: bool = False,
|
||||
):
|
||||
"""Publish printer status change (throttled to 1 update/sec per printer)."""
|
||||
if not self.enabled or not self.connected:
|
||||
return
|
||||
|
|
@ -275,6 +282,15 @@ class MQTTRelayService:
|
|||
"big_fan1_speed": state.big_fan1_speed,
|
||||
"big_fan2_speed": state.big_fan2_speed,
|
||||
"heatbreak_fan_speed": state.heatbreak_fan_speed,
|
||||
"left_aux_fan_speed": state.left_aux_fan_speed,
|
||||
"exhaust_fan_present": state.exhaust_fan_present,
|
||||
# Bambuddy-side gate, not printer telemetry (#2525). Mirrors what the
|
||||
# Web UI already receives via printer_state_to_dict, so an external
|
||||
# automation can tell "finished" from "finished and still waiting for
|
||||
# someone to clear the bed". Edge changes are also published on
|
||||
# printers/{serial}/plate_clear — this topic only refreshes when the
|
||||
# printer pushes telemetry, which stops entirely after Auto Off.
|
||||
"awaiting_plate_clear": awaiting_plate_clear,
|
||||
}
|
||||
|
||||
self._publish(
|
||||
|
|
@ -283,6 +299,37 @@ class MQTTRelayService:
|
|||
retain=True,
|
||||
)
|
||||
|
||||
async def on_plate_clear_state(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
printer_serial: str,
|
||||
awaiting: bool,
|
||||
):
|
||||
"""Publish the plate-clear gate as it flips (#2525).
|
||||
|
||||
Retained, unlike the other per-printer event topics, because this is a
|
||||
*state* an automation needs on subscribe rather than a moment it might
|
||||
have missed. The status topic carries the same field, but only refreshes
|
||||
when the printer pushes telemetry — after Auto Off cycles the printer the
|
||||
retained status payload would sit at ``awaiting_plate_clear: false``
|
||||
indefinitely while the gate is in fact still up.
|
||||
"""
|
||||
if not self.enabled or not self.connected:
|
||||
return
|
||||
|
||||
self._publish(
|
||||
f"{self.topic_prefix}/printers/{printer_serial}/plate_clear",
|
||||
{
|
||||
"printer_id": printer_id,
|
||||
"printer_name": printer_name,
|
||||
"printer_serial": printer_serial,
|
||||
"awaiting": awaiting,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
retain=True,
|
||||
)
|
||||
|
||||
async def on_printer_online(self, printer_id: int, printer_name: str, printer_serial: str):
|
||||
"""Publish printer came online event."""
|
||||
if not self.enabled or not self.connected:
|
||||
|
|
|
|||
|
|
@ -64,6 +64,55 @@ def _looks_like_cloudflare_challenge(response: httpx.Response) -> bool:
|
|||
return "just a moment" in body or "cf-chl-bypass" in body or "cf-chl-opt" in body or "challenge-platform" in body
|
||||
|
||||
|
||||
def _assert_safe_provider_url(url: str, *, label: str) -> str | None:
|
||||
"""Validate a provider URL taken from user-supplied config.
|
||||
|
||||
Returns an error message on rejection, or None when the URL is
|
||||
acceptable — the ``_send_*`` methods return ``tuple[bool, str]`` rather
|
||||
than raising, so a message is more useful here than an exception.
|
||||
|
||||
Uses the LAN-service policy: self-hosting ntfy, Bark, Gotify or a webhook
|
||||
receiver on the home LAN is normal and must keep working, so loopback and
|
||||
RFC-1918 stay permitted. Cloud-metadata endpoints, numeric-encoded IPs and
|
||||
non-HTTP schemes are rejected.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
assert_safe_lan_service_url(url, label=label)
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
|
||||
def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
|
||||
"""Failure message for a provider whose destination host the user supplies.
|
||||
|
||||
The response body is deliberately **not** returned to the caller. Provider
|
||||
URLs are configurable by anyone holding ``NOTIFICATIONS_CREATE`` — which
|
||||
the default Operators group carries and which does not imply
|
||||
``SETTINGS_UPDATE`` — and ``POST /notifications/test-config`` accepts a URL
|
||||
straight from the request body without persisting anything. Echoing the
|
||||
response body there turned an intended "does my webhook work?" check into
|
||||
an authenticated read primitive against any host the Bambuddy process can
|
||||
reach, including services that are not exposed to the network at all.
|
||||
|
||||
Providers whose host Bambuddy hardcodes (Pushover, Telegram, CallMeBot)
|
||||
keep returning the upstream body — there is no trust boundary to cross
|
||||
when the destination cannot be influenced.
|
||||
|
||||
The body is logged at debug level, where it stays available to whoever
|
||||
already administers the host without being handed back over the API.
|
||||
"""
|
||||
logger.debug(
|
||||
"%s delivery failed with HTTP %s; body: %s",
|
||||
label,
|
||||
response.status_code,
|
||||
(response.text or "")[:200],
|
||||
)
|
||||
return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for sending notifications through various providers."""
|
||||
|
||||
|
|
@ -225,6 +274,8 @@ class NotificationService:
|
|||
return await self._send_webhook(config, title, message)
|
||||
elif provider_type == "homeassistant":
|
||||
return await self._send_homeassistant(config, title, message, db=db)
|
||||
elif provider_type == "bark":
|
||||
return await self._send_bark(config, title, message)
|
||||
else:
|
||||
return False, f"Unknown provider type: {provider_type}"
|
||||
except Exception as e:
|
||||
|
|
@ -251,6 +302,56 @@ class NotificationService:
|
|||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
|
||||
async def _send_bark(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
||||
"""Send notification via Bark, the self-hostable iOS push service (#1495).
|
||||
|
||||
POSTs JSON to {server}/push. Defaults to the official api.day.app
|
||||
relay; a self-hosted bark-server works by overriding the server URL.
|
||||
"""
|
||||
server = (config.get("server") or "https://api.day.app").strip().rstrip("/")
|
||||
device_key = (config.get("device_key") or "").strip()
|
||||
|
||||
if not device_key:
|
||||
return False, "Device key is required"
|
||||
|
||||
url_error = _assert_safe_provider_url(server, label="Bark server URL")
|
||||
if url_error:
|
||||
return False, url_error
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"device_key": device_key,
|
||||
"title": title,
|
||||
"body": message,
|
||||
}
|
||||
group = (config.get("group") or "").strip()
|
||||
if group:
|
||||
payload["group"] = group
|
||||
sound = (config.get("sound") or "").strip()
|
||||
if sound:
|
||||
payload["sound"] = sound
|
||||
level = (config.get("level") or "").strip()
|
||||
if level in ("active", "timeSensitive", "critical", "passive"):
|
||||
payload["level"] = level
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.post(f"{server}/push", json=payload)
|
||||
|
||||
if response.status_code == 200:
|
||||
# bark-server can report failures inside an HTTP 200 body
|
||||
# ({"code": 400, "message": ...}), so the status alone isn't proof.
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if isinstance(body, dict) and body.get("code") not in (200, None):
|
||||
# Only the numeric code is echoed. A server chosen by the caller
|
||||
# controls this body too, so the free-text message is a (narrow)
|
||||
# read channel of the same kind _opaque_http_failure closes.
|
||||
logger.debug("Bark reported error %s: %s", body.get("code"), str(body.get("message"))[:200])
|
||||
return False, f"Bark error {body.get('code')} (see server logs at debug level for details)"
|
||||
return True, "Message sent successfully"
|
||||
return False, _opaque_http_failure(response, label="Bark server")
|
||||
|
||||
async def _send_ntfy(
|
||||
self,
|
||||
config: dict,
|
||||
|
|
@ -267,6 +368,10 @@ class NotificationService:
|
|||
if not topic:
|
||||
return False, "Topic is required"
|
||||
|
||||
url_error = _assert_safe_provider_url(server, label="ntfy server URL")
|
||||
if url_error:
|
||||
return False, url_error
|
||||
|
||||
url = f"{server}/{topic}"
|
||||
# ntfy reads Title/Message from HTTP headers. httpx enforces ASCII
|
||||
# for str header values, but printer names and filenames can contain
|
||||
|
|
@ -319,7 +424,7 @@ class NotificationService:
|
|||
"Fight Mode, or front the server with Cloudflare Access using a "
|
||||
"service token. (#1534)"
|
||||
)
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
return False, _opaque_http_failure(response, label="ntfy server")
|
||||
|
||||
async def _send_pushover(
|
||||
self, config: dict, title: str, message: str, image_data: bytes | None = None
|
||||
|
|
@ -394,6 +499,19 @@ class NotificationService:
|
|||
if not bot_token or not chat_id:
|
||||
return False, "Bot token and chat ID are required"
|
||||
|
||||
# Optional forum topic (#1518). Telegram expects message_thread_id as an
|
||||
# integer in the JSON sendMessage body — a string 400s there even though
|
||||
# the multipart sendPhoto call below would happily accept one. Coerce it
|
||||
# once, up front, so both call sites agree and a bad value fails loudly
|
||||
# instead of silently breaking only the text notifications.
|
||||
thread_id_raw = str(config.get("message_thread_id") or "").strip()
|
||||
message_thread_id: int | None = None
|
||||
if thread_id_raw:
|
||||
try:
|
||||
message_thread_id = int(thread_id_raw)
|
||||
except ValueError:
|
||||
return False, f"Invalid message thread ID: {thread_id_raw!r} is not a number"
|
||||
|
||||
# Escape underscores in the message body so Telegram Markdown
|
||||
# parsing doesn't break on job names like "A1_plate_8" or error
|
||||
# codes like "0300_0001". The title is already wrapped in *bold*
|
||||
|
|
@ -408,18 +526,23 @@ class NotificationService:
|
|||
if image_data:
|
||||
# Use sendPhoto to attach the thumbnail with the caption
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendPhoto"
|
||||
form: dict[str, Any] = {"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"}
|
||||
if message_thread_id is not None:
|
||||
form["message_thread_id"] = message_thread_id
|
||||
response = await client.post(
|
||||
url,
|
||||
data={"chat_id": chat_id, "caption": message, "parse_mode": "Markdown"},
|
||||
data=form,
|
||||
files={"photo": ("photo.jpg", image_data, "image/jpeg")},
|
||||
)
|
||||
else:
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
data = {
|
||||
data: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": "Markdown",
|
||||
}
|
||||
if message_thread_id is not None:
|
||||
data["message_thread_id"] = message_thread_id
|
||||
response = await client.post(url, json=data)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
|
@ -621,6 +744,10 @@ class NotificationService:
|
|||
if not webhook_url:
|
||||
return False, "Webhook URL is required"
|
||||
|
||||
url_error = _assert_safe_provider_url(webhook_url, label="Webhook URL")
|
||||
if url_error:
|
||||
return False, url_error
|
||||
|
||||
# Build payload based on format
|
||||
if payload_format == "slack":
|
||||
# Slack/Mattermost format - just text field
|
||||
|
|
@ -666,7 +793,7 @@ class NotificationService:
|
|||
if response.status_code in (200, 201, 202, 204):
|
||||
return True, "Webhook delivered successfully"
|
||||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
return False, _opaque_http_failure(response, label="webhook endpoint")
|
||||
except Exception as e:
|
||||
return False, f"Webhook error: {str(e)}"
|
||||
|
||||
|
|
@ -741,6 +868,24 @@ class NotificationService:
|
|||
"message": message,
|
||||
}
|
||||
|
||||
# Optional custom service-data (#1441), forwarded as HA's nested "data"
|
||||
# object so mobile-app push options (priority, ttl, channel, group, ...)
|
||||
# reach the notify service. Only included when configured — the default
|
||||
# persistent_notification.create schema rejects unknown keys.
|
||||
raw_data = config.get("data")
|
||||
if raw_data:
|
||||
if isinstance(raw_data, str):
|
||||
try:
|
||||
parsed_data = json.loads(raw_data)
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"Invalid JSON in the Data field: {e}"
|
||||
else:
|
||||
parsed_data = raw_data
|
||||
if not isinstance(parsed_data, dict):
|
||||
return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
|
||||
if parsed_data:
|
||||
payload["data"] = parsed_data
|
||||
|
||||
client = await self._get_client()
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
|
|
@ -749,7 +894,11 @@ class NotificationService:
|
|||
elif response.status_code == 401:
|
||||
return False, "Home Assistant authentication failed - check your token"
|
||||
else:
|
||||
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
||||
# ha_url comes from global settings (SETTINGS_UPDATE, admin-only), so
|
||||
# this is a narrower channel than the per-request provider URLs — but
|
||||
# it lands in the same NOTIFICATIONS_CREATE-gated test response, so it
|
||||
# gets the same treatment.
|
||||
return False, _opaque_http_failure(response, label="Home Assistant endpoint")
|
||||
|
||||
async def _send_to_provider(
|
||||
self,
|
||||
|
|
@ -794,6 +943,8 @@ class NotificationService:
|
|||
)
|
||||
elif provider.provider_type == "homeassistant":
|
||||
return await self._send_homeassistant(config, title, message, db=db)
|
||||
elif provider.provider_type == "bark":
|
||||
return await self._send_bark(config, title, message)
|
||||
else:
|
||||
return False, f"Unknown provider type: {provider.provider_type}"
|
||||
except Exception as e:
|
||||
|
|
@ -1317,6 +1468,39 @@ class NotificationService:
|
|||
variables=variables,
|
||||
)
|
||||
|
||||
async def on_plate_clear_required(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle plate-clear-required event — a print ended and the queue is gated (#2525).
|
||||
|
||||
Distinct from ``on_plate_not_empty``, which is the camera check *before* a
|
||||
print starts. This one fires on the rising edge of the Bambuddy-side
|
||||
awaiting-plate-clear flag, i.e. whenever a print reaches a terminal state
|
||||
and the next queued job can't dispatch until someone confirms the bed is
|
||||
free. Off by default on every provider: it lands at the same moment as the
|
||||
print-complete notification, so opting in is a deliberate choice.
|
||||
"""
|
||||
providers = await self._get_providers_for_event(db, "on_plate_clear_required", printer_id)
|
||||
if not providers:
|
||||
return
|
||||
|
||||
variables = {"printer": printer_name}
|
||||
|
||||
title, message = await self._build_message_from_template(db, "plate_clear_required", variables)
|
||||
await self._send_to_providers(
|
||||
providers,
|
||||
title,
|
||||
message,
|
||||
db,
|
||||
"plate_clear_required",
|
||||
printer_id,
|
||||
printer_name,
|
||||
variables=variables,
|
||||
)
|
||||
|
||||
async def on_filament_low(
|
||||
self,
|
||||
printer_id: int,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ _frame_cache: dict[str, tuple[bytes, float]] = {}
|
|||
_frame_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def auth_headers(token: str | None) -> dict[str, str]:
|
||||
"""Bearer header for the ML API, or nothing when no token is configured.
|
||||
|
||||
Obico's ML API gates ``/p/`` behind ``ML_API_TOKEN`` (``ml_api/auth.py``):
|
||||
with the variable set it answers a bare 401 to any request whose
|
||||
``Authorization`` header isn't ``Bearer <token>``, and with it unset it
|
||||
ignores the header entirely. Sending nothing when unconfigured keeps the
|
||||
request byte-identical to what shipped before the setting existed.
|
||||
"""
|
||||
token = (token or "").strip()
|
||||
return {"Authorization": f"Bearer {token}"} if token else {}
|
||||
|
||||
|
||||
def _prune_frame_cache() -> None:
|
||||
"""Drop entries older than FRAME_CACHE_TTL. Called under the cache lock."""
|
||||
now = time.monotonic()
|
||||
|
|
@ -111,6 +124,7 @@ class ObicoDetectionService:
|
|||
keys = [
|
||||
"obico_enabled",
|
||||
"obico_ml_url",
|
||||
"obico_ml_token",
|
||||
"obico_sensitivity",
|
||||
"obico_action",
|
||||
"obico_poll_interval",
|
||||
|
|
@ -133,6 +147,7 @@ class ObicoDetectionService:
|
|||
return {
|
||||
"enabled": rows.get("obico_enabled", "false").lower() == "true",
|
||||
"ml_url": (rows.get("obico_ml_url") or "").rstrip("/"),
|
||||
"ml_token": (rows.get("obico_ml_token") or "").strip(),
|
||||
"sensitivity": rows.get("obico_sensitivity", "medium"),
|
||||
"action": rows.get("obico_action", "notify"),
|
||||
"poll_interval": int(rows.get("obico_poll_interval", "10")),
|
||||
|
|
@ -193,6 +208,21 @@ class ObicoDetectionService:
|
|||
return None
|
||||
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
# Same rule as the built-in branch below, which this used to skip:
|
||||
# an external camera is single-reader too, so polling while a viewer
|
||||
# is attached just fails (#2707).
|
||||
from backend.app.api.routes.camera import live_frame_for_capture
|
||||
|
||||
defer, buffered = live_frame_for_capture(printer_id)
|
||||
if defer:
|
||||
if buffered:
|
||||
return buffered
|
||||
logger.info(
|
||||
"Obico: viewer attached for printer %s but buffer empty; "
|
||||
"skipping this poll to avoid competing camera handle (#2707)",
|
||||
printer_id,
|
||||
)
|
||||
return None
|
||||
return await capture_external_frame(
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type,
|
||||
|
|
@ -264,7 +294,23 @@ class ObicoDetectionService:
|
|||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=DETECTION_TIMEOUT) as client:
|
||||
resp = await client.get(ml_url, params={"img": snapshot_url})
|
||||
resp = await client.get(
|
||||
ml_url,
|
||||
params={"img": snapshot_url},
|
||||
headers=auth_headers(settings.get("ml_token")),
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
# The server runs with ML_API_TOKEN set and rejected ours.
|
||||
# Say so plainly: the health endpoint is ungated, so "Test
|
||||
# Connection" passes against exactly this configuration and
|
||||
# a raw 401 gives the user nothing to act on (#2733).
|
||||
self._last_error = (
|
||||
"Obico ML API rejected the token (401). Set Settings → Failure Detection → "
|
||||
"ML API Token to the ML_API_TOKEN the server runs with, or clear ML_API_TOKEN "
|
||||
"on the server."
|
||||
)
|
||||
logger.warning("%s (printer %s)", self._last_error, printer_id)
|
||||
return
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as e:
|
||||
|
|
@ -320,6 +366,21 @@ class ObicoDetectionService:
|
|||
|
||||
# ---- queries ----
|
||||
|
||||
def get_per_printer(self) -> dict:
|
||||
"""Live classification per actively monitored printer.
|
||||
|
||||
Only printers with a running, monitored print have a state entry, so
|
||||
consumers get "show nothing" for idle printers for free.
|
||||
"""
|
||||
return {
|
||||
pid: {
|
||||
"class": self._last_class.get(pid, "safe"),
|
||||
"frame_count": state.frame_count,
|
||||
"score": round(state.ewm_mean, 4),
|
||||
}
|
||||
for pid, state in self._states.items()
|
||||
}
|
||||
|
||||
def get_status(self, sensitivity: str = "medium") -> dict:
|
||||
# Report the thresholds for the configured sensitivity, not a hardcoded
|
||||
# "medium" — otherwise the Status panel always shows the medium row
|
||||
|
|
@ -329,33 +390,86 @@ class ObicoDetectionService:
|
|||
return {
|
||||
"is_running": self._task is not None and not self._task.done(),
|
||||
"last_error": self._last_error,
|
||||
"per_printer": {
|
||||
pid: {
|
||||
"class": self._last_class.get(pid, "safe"),
|
||||
"frame_count": state.frame_count,
|
||||
"score": round(state.ewm_mean, 4),
|
||||
}
|
||||
for pid, state in self._states.items()
|
||||
},
|
||||
"per_printer": self.get_per_printer(),
|
||||
"thresholds": {"low": low, "high": high},
|
||||
"history": list(self._history),
|
||||
}
|
||||
|
||||
async def test_connection(self, url: str) -> dict:
|
||||
"""Ping the ML API health endpoint. Returns {ok, status_code, body, error}."""
|
||||
target = f"{url.rstrip('/')}/hc/"
|
||||
async def test_connection(self, url: str, token: str = "") -> dict:
|
||||
"""Ping the ML API and check the token. Returns {ok, status_code, body, error, auth_ok}.
|
||||
|
||||
The stored ``obico_ml_url`` setting is validated at the schema layer,
|
||||
but this route takes its URL from the request body, so the same
|
||||
LAN-service policy has to be applied here or the guard is trivially
|
||||
sidestepped by testing a URL instead of saving it. The response body
|
||||
is returned to the caller (it is the health signal — the endpoint
|
||||
answers "ok"), which is exactly why the destination must be inside
|
||||
policy before the request is made.
|
||||
|
||||
``token`` is used verbatim — resolving "not supplied" to the saved
|
||||
setting is the route's job, so this stays a pure outbound call.
|
||||
|
||||
Health alone cannot answer whether the token works, because Obico
|
||||
gates ``/p/`` but leaves ``/hc/`` open — which is how a token-protected
|
||||
server passed this test while every detection call came back 401
|
||||
(#2733). So a second, side-effect-free probe follows: ``/p/`` with no
|
||||
``img`` parameter. The auth decorator runs before the handler, so 401
|
||||
means the token was rejected and 422 ("Invalid request params") means
|
||||
it was accepted. No inference work is done either way.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
assert_safe_lan_service_url(url, label="Obico ML URL")
|
||||
except ValueError as exc:
|
||||
return {"ok": False, "status_code": None, "body": None, "error": str(exc), "auth_ok": None}
|
||||
|
||||
headers = auth_headers(token)
|
||||
|
||||
base = url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HEALTH_TIMEOUT) as client:
|
||||
resp = await client.get(target)
|
||||
body = resp.text.strip()
|
||||
return {
|
||||
"ok": resp.status_code == 200 and body.lower() == "ok",
|
||||
"status_code": resp.status_code,
|
||||
"body": body,
|
||||
"error": None,
|
||||
}
|
||||
resp = await client.get(f"{base}/hc/", headers=headers)
|
||||
body = resp.text.strip()
|
||||
healthy = resp.status_code == 200 and body.lower() == "ok"
|
||||
if not healthy:
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": resp.status_code,
|
||||
"body": body,
|
||||
"error": None,
|
||||
"auth_ok": None,
|
||||
}
|
||||
|
||||
auth_ok: bool | None
|
||||
try:
|
||||
probe = await client.get(f"{base}/p/", headers=headers)
|
||||
auth_ok = probe.status_code != 401
|
||||
except Exception:
|
||||
# The health check already succeeded, so don't fail the
|
||||
# whole test on the probe — report the token as unknown.
|
||||
auth_ok = None
|
||||
except Exception as e:
|
||||
return {"ok": False, "status_code": None, "body": None, "error": str(e) or type(e).__name__}
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": None,
|
||||
"body": None,
|
||||
"error": str(e) or type(e).__name__,
|
||||
"auth_ok": None,
|
||||
}
|
||||
|
||||
if auth_ok is False:
|
||||
return {
|
||||
"ok": False,
|
||||
"status_code": 401,
|
||||
"body": body,
|
||||
"error": (
|
||||
"The ML API is reachable but rejected the token. It runs with ML_API_TOKEN set — "
|
||||
"enter that value as the ML API Token, or clear ML_API_TOKEN on the server."
|
||||
),
|
||||
"auth_ok": False,
|
||||
}
|
||||
return {"ok": True, "status_code": resp.status_code, "body": body, "error": None, "auth_ok": auth_ok}
|
||||
|
||||
|
||||
obico_detection_service = ObicoDetectionService()
|
||||
|
|
|
|||
|
|
@ -604,16 +604,28 @@ async def capture_camera_image(
|
|||
# Try external camera first if requested and available
|
||||
if use_external and external_camera_url and external_camera_type:
|
||||
try:
|
||||
from backend.app.api.routes.camera import live_frame_for_capture
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
image_data = await capture_frame(
|
||||
external_camera_url,
|
||||
external_camera_type,
|
||||
snapshot_url=external_camera_snapshot_url,
|
||||
)
|
||||
if image_data:
|
||||
camera_source = "external"
|
||||
logger.debug("Captured frame from external camera for printer %s", printer_id)
|
||||
# What this function's docstring already promised, but only the
|
||||
# built-in fallback below delivered: an external camera is
|
||||
# single-reader too, so capturing while a viewer watches fails
|
||||
# (#2707).
|
||||
defer, buffered = live_frame_for_capture(printer_id)
|
||||
if defer:
|
||||
if buffered:
|
||||
image_data = buffered
|
||||
camera_source = "external (buffered)"
|
||||
logger.debug("Using buffered external frame for printer %s", printer_id)
|
||||
else:
|
||||
image_data = await capture_frame(
|
||||
external_camera_url,
|
||||
external_camera_type,
|
||||
snapshot_url=external_camera_snapshot_url,
|
||||
)
|
||||
if image_data:
|
||||
camera_source = "external"
|
||||
logger.debug("Captured frame from external camera for printer %s", printer_id)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to capture from external camera: %s", e)
|
||||
|
||||
|
|
|
|||
68
backend/app/services/print_dispatch_context.py
Normal file
68
backend/app/services/print_dispatch_context.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Whether Bambuddy injected End G-code into the print now running (#2547).
|
||||
|
||||
The finish-photo path has to know one thing at print completion that no MQTT
|
||||
field reports: did this print end with user End G-code? If it did, a SwapMod
|
||||
snippet may already have ejected the plate, so the scene in front of the camera
|
||||
at ``gcode_state=FINISH`` is not the finished print and the photo must come from
|
||||
the in-print frame bank instead (#1867).
|
||||
|
||||
Only the dispatcher ever sees this, so it is recorded here in two steps:
|
||||
|
||||
1. ``mark_pending`` when the scheduler injects an End G-code snippet.
|
||||
2. ``adopt`` when the printer reports a print starting, which moves the pending
|
||||
flag onto the running print and consumes it.
|
||||
|
||||
The two steps exist so the flag can never outlive its print. A print Bambuddy
|
||||
did not dispatch — started from the slicer, the SD card, or the printer's own
|
||||
screen — finds no pending flag and correctly adopts ``False``, instead of
|
||||
inheriting the answer from whatever ran before it.
|
||||
|
||||
In-memory and best-effort: a restart mid-print loses the flag, and ``False`` is
|
||||
the safe way to be wrong (a live grab that might show a swapped plate, rather
|
||||
than silently substituting a mid-print frame).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Printers the scheduler has injected End G-code for, awaiting a print start.
|
||||
_pending: set[int] = set()
|
||||
# Printers whose *currently running* print has injected End G-code.
|
||||
_active: set[int] = set()
|
||||
|
||||
|
||||
def mark_pending(printer_id: int) -> None:
|
||||
"""Record that the job now being sent to ``printer_id`` has End G-code."""
|
||||
_pending.add(printer_id)
|
||||
logger.debug("[DISPATCH-CTX] printer %s: End G-code injected, awaiting print start", printer_id)
|
||||
|
||||
|
||||
def adopt(printer_id: int) -> bool:
|
||||
"""Bind any pending flag to the print that just started, and return it.
|
||||
|
||||
Called once per print start. Always writes ``_active`` — including the
|
||||
``False`` case — so a print Bambuddy didn't dispatch clears its
|
||||
predecessor's flag rather than inheriting it.
|
||||
"""
|
||||
injected = printer_id in _pending
|
||||
_pending.discard(printer_id)
|
||||
if injected:
|
||||
_active.add(printer_id)
|
||||
logger.debug("[DISPATCH-CTX] printer %s: running print has injected End G-code", printer_id)
|
||||
else:
|
||||
_active.discard(printer_id)
|
||||
return injected
|
||||
|
||||
|
||||
def end_gcode_injected(printer_id: int) -> bool:
|
||||
"""True if the print currently running on ``printer_id`` has End G-code."""
|
||||
return printer_id in _active
|
||||
|
||||
|
||||
def clear(printer_id: int) -> None:
|
||||
"""Forget everything about this printer (disconnect, removal, tests)."""
|
||||
_pending.discard(printer_id)
|
||||
_active.discard(printer_id)
|
||||
|
|
@ -23,6 +23,7 @@ from backend.app.models.settings import Settings
|
|||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.spool_assignment import SpoolAssignment
|
||||
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
|
||||
from backend.app.services import print_dispatch_context
|
||||
from backend.app.services.bambu_ftp import (
|
||||
UploadCancelled,
|
||||
cache_3mf_download,
|
||||
|
|
@ -31,6 +32,7 @@ from backend.app.services.bambu_ftp import (
|
|||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
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.notification_service import notification_service
|
||||
from backend.app.services.printer_manager import (
|
||||
|
|
@ -173,6 +175,25 @@ def _mapping_is_all_unresolved(mapping: list | None) -> bool:
|
|||
return all(t is None or (isinstance(t, int) and t < 0) for t in mapping)
|
||||
|
||||
|
||||
def _mqtt_commands_rejected(status) -> bool:
|
||||
"""True when the printer is currently reporting that it refused a command.
|
||||
|
||||
``HMS_MQTT_VERIFY_FAILED`` means the firmware's authorization check rejected
|
||||
a control command it could not verify. Queries still answer, so the printer
|
||||
looks connected and idle while project_file, gcode_line and
|
||||
ams_change_filament are all dropped — no amount of waiting or re-uploading
|
||||
changes that (#2732).
|
||||
|
||||
Tolerates a missing status and errors without a ``full_code`` (the 8-char
|
||||
``print_error`` path builds HMSError differently), so this is safe to call on
|
||||
every watchdog poll.
|
||||
"""
|
||||
for err in getattr(status, "hms_errors", None) or []:
|
||||
if getattr(err, "full_code", "") == HMS_MQTT_VERIFY_FAILED:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _installed_nozzle_diameters(status) -> list[float]:
|
||||
"""Parse the installed nozzle diameters from a PrinterState (#1899).
|
||||
|
||||
|
|
@ -280,17 +301,29 @@ class PrintScheduler:
|
|||
# event-loop thread, so this dict needs no lock.
|
||||
# item_id -> (task, printer_id)
|
||||
self._inflight: dict[int, tuple[asyncio.Task, int | None]] = {}
|
||||
# Expected prints registered by `_start_print` that have not yet had a
|
||||
# print command sent. Populated at registration, dropped once
|
||||
# `start_print()` succeeds, and rolled back by `_dispatch_one` on every
|
||||
# other exit. Same threading argument as `_inflight` above: one
|
||||
# sequential caller, callbacks on the same loop, so no lock.
|
||||
# item_id -> (printer_id, remote_filename, archive_id)
|
||||
self._unconfirmed_expected_print: dict[int, tuple[int, str, int]] = {}
|
||||
|
||||
async def run(self):
|
||||
"""Main loop - check queue every interval."""
|
||||
self._running = True
|
||||
logger.info("Print scheduler started")
|
||||
|
||||
await self._clear_stale_dispatch_claims()
|
||||
await self._clear_stale_dispatch_claims(at_startup=True)
|
||||
|
||||
while self._running:
|
||||
dispatched = False
|
||||
try:
|
||||
# No-op while any upload is in flight; on a quiet tick it releases
|
||||
# a claim whose best-effort clear failed (e.g. the database was
|
||||
# briefly unreachable), instead of leaving the row wedged until
|
||||
# the next restart.
|
||||
await self._clear_stale_dispatch_claims()
|
||||
dispatched = await self.check_queue()
|
||||
except Exception as e:
|
||||
logger.error("Scheduler error: %s", e)
|
||||
|
|
@ -299,14 +332,29 @@ class PrintScheduler:
|
|||
# not stall behind the idle interval; otherwise sleep normally (#2555).
|
||||
await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
|
||||
|
||||
async def _clear_stale_dispatch_claims(self) -> None:
|
||||
"""Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
|
||||
async def _clear_stale_dispatch_claims(self, *, at_startup: bool = False) -> None:
|
||||
"""Clear dispatch claims with no live dispatch coroutine behind them (#2615).
|
||||
|
||||
A claim is only ever held by a live dispatch coroutine, and no coroutine
|
||||
survives a process restart — so every ``dispatching_at`` present at startup
|
||||
is stale. Clearing them lets those still-pending rows be re-selected for a
|
||||
fresh, consistent dispatch instead of being wedged out of the selection
|
||||
query forever. Called once at the top of ``run()``."""
|
||||
A claim is only ever held by a live dispatch coroutine, so when this
|
||||
process has nothing in ``_inflight`` every ``dispatching_at`` in the table
|
||||
is stale. At startup that is trivially true — no coroutine survives a
|
||||
restart. It is equally true on any later tick where no upload is running,
|
||||
which is what makes this safe to repeat rather than only run once.
|
||||
|
||||
Repeating it matters because ``_clear_dispatch_claim`` is best-effort: if
|
||||
the database is briefly unreachable at exactly the moment dispatch ends,
|
||||
the claim survives and the row is wedged out of the selection query. That
|
||||
used to last until the next restart (#2702 follow-up, seen when
|
||||
PostgreSQL refused a connection mid-dispatch).
|
||||
|
||||
``_inflight`` is populated when the task is spawned, before the coroutine
|
||||
claims its row, and pruned by a done-callback that cannot run before the
|
||||
coroutine's own ``finally`` — so "claim present, nothing in flight" has no
|
||||
race window and needs no age threshold. A size-derived upload deadline
|
||||
(``max(600s, size/25KB/s)``) has no safe fixed bound anyway.
|
||||
"""
|
||||
if self._inflight:
|
||||
return
|
||||
try:
|
||||
async with async_session() as db:
|
||||
res = await db.execute(
|
||||
|
|
@ -314,9 +362,13 @@ class PrintScheduler:
|
|||
)
|
||||
await db.commit()
|
||||
if res.rowcount:
|
||||
logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
|
||||
logger.info(
|
||||
"Cleared %d orphaned dispatch claim(s)%s (#2615)",
|
||||
res.rowcount,
|
||||
" at startup" if at_startup else "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
|
||||
logger.error("Failed to clear orphaned dispatch claims: %s", exc)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler."""
|
||||
|
|
@ -929,6 +981,14 @@ class PrintScheduler:
|
|||
return
|
||||
await self._start_print(item_db, item)
|
||||
finally:
|
||||
# Undo an expected-print registration whose print command never
|
||||
# went out. One choke point covers every way `_start_print` can
|
||||
# end without sending: a raised exception (a DB failure mid-
|
||||
# dispatch is the reported case), an early return, a cancel
|
||||
# winning the #1853 CAS, or `start_print()` returning False.
|
||||
# A confirmed send removes the entry itself, so this is a no-op
|
||||
# on the happy path.
|
||||
self._rollback_unconfirmed_expected_print(item_id)
|
||||
# Release the claim on every exit. Once dispatch has finished the
|
||||
# row's status carries the lock (printing/failed/cancelled are all
|
||||
# != pending), so the token is only needed for the duration of the
|
||||
|
|
@ -936,6 +996,30 @@ class PrintScheduler:
|
|||
# dispatchable again on the next tick.
|
||||
await self._clear_dispatch_claim(item_db, item_id)
|
||||
|
||||
def _rollback_unconfirmed_expected_print(self, item_id: int) -> None:
|
||||
"""Drop an expectation for a print command that was never sent.
|
||||
|
||||
Best-effort and never raises: this runs in the ``finally`` of dispatch,
|
||||
where the interesting exception is usually the one already propagating.
|
||||
"""
|
||||
pending = self._unconfirmed_expected_print.pop(item_id, None)
|
||||
if pending is None:
|
||||
return
|
||||
printer_id, remote_filename, archive_id = pending
|
||||
try:
|
||||
from backend.app.main import unregister_expected_print
|
||||
|
||||
unregister_expected_print(printer_id, remote_filename, archive_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Queue item %s: failed to unregister expected print (printer=%s, file=%s, archive=%s)",
|
||||
item_id,
|
||||
printer_id,
|
||||
remote_filename,
|
||||
archive_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
|
||||
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
|
||||
|
||||
|
|
@ -954,12 +1038,39 @@ class PrintScheduler:
|
|||
|
||||
async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
|
||||
"""Clear the dispatch claim (#2615). Best-effort: a failure here must not
|
||||
mask the dispatch outcome, and startup reconciliation clears any leftover."""
|
||||
try:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
|
||||
mask the dispatch outcome.
|
||||
|
||||
Retried, because the failure mode in practice is transient and narrow: a
|
||||
database that is momentarily unreachable — PostgreSQL out of connection
|
||||
slots is the observed case — refuses this write for a second or two while
|
||||
the dispatch that just ended is still holding the row out of the selection
|
||||
query. One attempt was enough to wedge the item; a couple of spaced
|
||||
attempts clear it. Each attempt rolls back first, since a failed write
|
||||
leaves the session needing it before it can be reused.
|
||||
|
||||
If every attempt fails, ``_clear_stale_dispatch_claims`` picks the row up
|
||||
on the next quiet tick.
|
||||
"""
|
||||
for attempt in range(1, 4):
|
||||
try:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
|
||||
await db.commit()
|
||||
return
|
||||
except Exception as exc:
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
if attempt == 3:
|
||||
logger.warning(
|
||||
"Queue item %s: failed to clear dispatch claim after %d attempts: %s "
|
||||
"— a later quiet tick will release it",
|
||||
item_id,
|
||||
attempt,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(0.5 * attempt)
|
||||
|
||||
async def _find_idle_printer_for_model(
|
||||
self,
|
||||
|
|
@ -1158,6 +1269,14 @@ class PrintScheduler:
|
|||
to carry ``force_color_match: True``. The printer must have **every** such slot loaded
|
||||
with an exact type+color match.
|
||||
|
||||
When both the override and a candidate tray carry a ``tray_info_idx``, they must also
|
||||
match on it: Bambu reports every PLA variant as ``tray_type == "PLA"``, so the
|
||||
Basic/Matte/Silk distinction lives only in ``tray_info_idx`` (GFA00/GFA01/GFA06/...).
|
||||
Without this, a job sliced for PLA Matte matched every white PLA regardless of variant
|
||||
(#2650). If either side lacks an idx (custom/third-party spools report a blank one, and
|
||||
older 3MFs carry none) we fall back to the historical type+colour behaviour so those
|
||||
setups are unaffected.
|
||||
|
||||
Returns:
|
||||
List of ``"TYPE (color)"`` strings for unmatched slots (empty list means all match).
|
||||
"""
|
||||
|
|
@ -1165,26 +1284,32 @@ class PrintScheduler:
|
|||
if not status:
|
||||
return [f"{o.get('type', '?')} ({o.get('color_name') or o.get('color', '?')})" for o in force_overrides]
|
||||
|
||||
# Build set of loaded type+colour pairs from AMS and external spool
|
||||
loaded: set[tuple[str, str]] = set()
|
||||
# Build loaded (type, colour, tray_info_idx) triples from AMS and external spool.
|
||||
loaded: list[tuple[str, str, str]] = []
|
||||
for ams_unit in status.raw_data.get("ams", []):
|
||||
for tray in ams_unit.get("tray", []):
|
||||
tray_type = tray.get("tray_type")
|
||||
tray_color = tray.get("tray_color", "")
|
||||
if tray_type:
|
||||
color_norm = tray_color.replace("#", "").lower()[:6]
|
||||
loaded.add((_canonical_filament_type(tray_type), color_norm))
|
||||
color_norm = (tray.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
||||
loaded.append(
|
||||
(_canonical_filament_type(tray_type), color_norm, tray.get("tray_info_idx", "") or "")
|
||||
)
|
||||
for vt in status.raw_data.get("vt_tray") or []:
|
||||
vt_type = vt.get("tray_type")
|
||||
if vt_type:
|
||||
color_norm = (vt.get("tray_color", "") or "").replace("#", "").lower()[:6]
|
||||
loaded.add((_canonical_filament_type(vt_type), color_norm))
|
||||
loaded.append((_canonical_filament_type(vt_type), color_norm, vt.get("tray_info_idx", "") or ""))
|
||||
|
||||
missing = []
|
||||
for o in force_overrides:
|
||||
o_type = _canonical_filament_type(o.get("type") or "")
|
||||
o_color = (o.get("color") or "").replace("#", "").lower()[:6]
|
||||
if (o_type, o_color) not in loaded:
|
||||
o_idx = o.get("tray_info_idx") or ""
|
||||
satisfied = any(
|
||||
t_type == o_type and t_color == o_color and (not o_idx or not t_idx or o_idx == t_idx)
|
||||
for t_type, t_color, t_idx in loaded
|
||||
)
|
||||
if not satisfied:
|
||||
color_label = o.get("color_name") or o.get("color", "?")
|
||||
missing.append(f"{o_type} ({color_label})")
|
||||
return missing
|
||||
|
|
@ -1374,9 +1499,18 @@ class PrintScheduler:
|
|||
override = override_map[req["slot_id"]]
|
||||
req["type"] = override["type"]
|
||||
req["color"] = override["color"]
|
||||
# Clear tray_info_idx so matching uses type+color instead of
|
||||
# the original 3MF's tray_info_idx (which would match the old filament)
|
||||
req["tray_info_idx"] = ""
|
||||
# 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,
|
||||
|
|
@ -1440,7 +1574,11 @@ class PrintScheduler:
|
|||
"slot_id": o["slot_id"],
|
||||
"type": o.get("type", ""),
|
||||
"color": o.get("color", ""),
|
||||
"tray_info_idx": "",
|
||||
# These are all force_color_match overrides, so the idx (when the
|
||||
# 3MF carried one) is the intended variant, not a stale swap —
|
||||
# keep it so the matcher pins the right variant slot, falling back
|
||||
# to type+colour when it isn't loaded (#2650).
|
||||
"tray_info_idx": o.get("tray_info_idx", ""),
|
||||
}
|
||||
for o in force_overrides
|
||||
]
|
||||
|
|
@ -2849,6 +2987,7 @@ class PrintScheduler:
|
|||
queue_item_id: int,
|
||||
printer_id: int,
|
||||
created_by_id: int | None,
|
||||
reason: str = "Printer accepted the file but never started printing",
|
||||
) -> None:
|
||||
"""Tell the user the queue item was failed after exhausting its dispatch retries.
|
||||
|
||||
|
|
@ -2856,6 +2995,10 @@ class PrintScheduler:
|
|||
its own — hence the fresh one here. Best-effort throughout: the row is
|
||||
already marked failed and that is the load-bearing part; a notification
|
||||
provider being down must not resurrect the retry loop we just stopped.
|
||||
|
||||
``reason`` defaults to the exhausted-retries wording. The command-rejected
|
||||
path passes its own, because "accepted the file but never started" is the
|
||||
opposite of what happened there — the printer refused it outright (#2732).
|
||||
"""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
|
|
@ -2868,7 +3011,7 @@ class PrintScheduler:
|
|||
job_name=job_name,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer.name if printer else "Unknown",
|
||||
reason="Printer accepted the file but never started printing",
|
||||
reason=reason,
|
||||
db=db,
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -3095,6 +3238,7 @@ class PrintScheduler:
|
|||
original_filename=filename,
|
||||
created_by_id=item.created_by_id,
|
||||
project_id=item.project_id,
|
||||
library_file_id=item.library_file_id, # per-file project progress (#1897)
|
||||
plate_id=item.plate_id, # selected plate → Print History (#2603)
|
||||
)
|
||||
if archive:
|
||||
|
|
@ -3218,6 +3362,10 @@ class PrintScheduler:
|
|||
|
||||
# G-code injection for auto-print systems (#422)
|
||||
injected_path = None
|
||||
# #2547: tracked separately from `injected_path`, which is also set when
|
||||
# only a START snippet was injected. Only an END snippet changes what the
|
||||
# camera sees at print completion.
|
||||
end_gcode_injected = False
|
||||
if item.gcode_injection:
|
||||
try:
|
||||
snippets_raw = await self._get_setting(db, "gcode_snippets")
|
||||
|
|
@ -3234,6 +3382,7 @@ class PrintScheduler:
|
|||
)
|
||||
if injected_path:
|
||||
file_path = injected_path
|
||||
end_gcode_injected = bool(end_gc)
|
||||
logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
|
||||
else:
|
||||
logger.warning(
|
||||
|
|
@ -3242,6 +3391,13 @@ class PrintScheduler:
|
|||
except Exception as e:
|
||||
logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
|
||||
|
||||
# #2547: the finish-photo path can't learn from telemetry that this print
|
||||
# ends with user End G-code — which means the plate may be gone by the
|
||||
# time FINISH arrives (#1867). Flag it here; `on_print_start` binds it to
|
||||
# the print once the printer confirms it running.
|
||||
if end_gcode_injected:
|
||||
print_dispatch_context.mark_pending(printer.id)
|
||||
|
||||
# Upload to root directory (not /cache/) - the start_print command references
|
||||
# files by name only (ftp://{filename}), so they must be in the root
|
||||
remote_filename = derive_remote_filename(filename)
|
||||
|
|
@ -3404,6 +3560,12 @@ class PrintScheduler:
|
|||
created_by_id=item.created_by_id,
|
||||
plate_id=item.plate_id,
|
||||
)
|
||||
# Registration happens before the print command by necessity (the
|
||||
# printer can report the print before the send returns), so record
|
||||
# what to undo if we never get as far as sending. `_dispatch_one`
|
||||
# rolls back anything still pending here on every exit — exception,
|
||||
# early return, or cancel winning the CAS below.
|
||||
self._unconfirmed_expected_print[item.id] = (item.printer_id, remote_filename, archive.id)
|
||||
|
||||
# Propagate the queue item's owner into printer_manager so the
|
||||
# print-complete callback can credit the user in the PrintLogEntry
|
||||
|
|
@ -3528,6 +3690,10 @@ class PrintScheduler:
|
|||
)
|
||||
|
||||
if started:
|
||||
# The command is away, so the expectation is now legitimate and must
|
||||
# survive. Anything still in this dict when _dispatch_one exits gets
|
||||
# rolled back.
|
||||
self._unconfirmed_expected_print.pop(item.id, None)
|
||||
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
|
||||
# No dispatch-toast event here: the legacy bg-dispatch path kept
|
||||
# status='processing' from upload start until the printer acked
|
||||
|
|
@ -3716,9 +3882,20 @@ class PrintScheduler:
|
|||
|
||||
Phase A timeout raised from 45 s → 90 s as belt-and-braces for slow
|
||||
transitions that also don't emit an early subtask_id tick.
|
||||
|
||||
Both phases also watch for ``HMS_MQTT_VERIFY_FAILED``. A printer that
|
||||
refuses to verify our commands will never start this job or any other,
|
||||
so waiting out the full 270 s and re-uploading the 3MF twice more only
|
||||
burns an upload slot the rest of the farm is queued behind — that path
|
||||
is for a printer that might still come good, which this one cannot
|
||||
(#2732). It fails the item on the spot with the actual reason instead.
|
||||
"""
|
||||
last_status = None
|
||||
landed_on_subtask = False
|
||||
# Latched, not level-tested: state.hms_errors is rebuilt from scratch on
|
||||
# every push carrying an `hms` key, so the fault can come and go between
|
||||
# 3-second polls. Seeing it once inside the dispatch window is enough.
|
||||
command_rejected = False
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
|
@ -3749,6 +3926,13 @@ class PrintScheduler:
|
|||
except Exception:
|
||||
pass
|
||||
return
|
||||
# Checked only after the active-state exit above: a stale HMS left
|
||||
# over from an earlier job must never abort a print that is visibly
|
||||
# running. An actually-refused command leaves the printer idle, so
|
||||
# this ordering costs the detection nothing.
|
||||
if _mqtt_commands_rejected(status):
|
||||
command_rejected = True
|
||||
break
|
||||
if pre_subtask_id is not None and status.subtask_id is not None and status.subtask_id != pre_subtask_id:
|
||||
# Phase A exit — printer accepted the file (subtask_id flipped
|
||||
# to our submission id). Don't return yet: the printer may
|
||||
|
|
@ -3758,7 +3942,7 @@ class PrintScheduler:
|
|||
landed_on_subtask = True
|
||||
break
|
||||
|
||||
if landed_on_subtask:
|
||||
if landed_on_subtask and not command_rejected:
|
||||
phase_b_deadline = time.monotonic() + phase_b_timeout
|
||||
while time.monotonic() < phase_b_deadline:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
|
@ -3778,6 +3962,11 @@ class PrintScheduler:
|
|||
except Exception:
|
||||
pass
|
||||
return
|
||||
# Same ordering rule as Phase A: a running print wins over a
|
||||
# lingering HMS.
|
||||
if _mqtt_commands_rejected(status):
|
||||
command_rejected = True
|
||||
break
|
||||
|
||||
# No active-state transition. Revert the item so the scheduler can retry.
|
||||
# Drop the in-memory hold so the retry isn't blocked by it.
|
||||
|
|
@ -3807,6 +3996,20 @@ class PrintScheduler:
|
|||
return "already_moved_on"
|
||||
item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
|
||||
item.started_at = None
|
||||
if command_rejected:
|
||||
# No retry budget for this one: the printer refused to verify the
|
||||
# command, and re-uploading the same 3MF to the same printer will
|
||||
# be refused the same way. Fail now with the fix rather than after
|
||||
# three laps of a message about SD cards (#2732).
|
||||
item.status = "failed"
|
||||
item.error_message = (
|
||||
"The printer rejected the print command: MQTT command verification failed "
|
||||
"(HMS 0500-0500-0001-0007). Enable Developer Mode on the printer, restart it, "
|
||||
"then start the job again."
|
||||
)
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return "command_rejected"
|
||||
if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
|
||||
item.status = "failed"
|
||||
item.error_message = (
|
||||
|
|
@ -3841,6 +4044,25 @@ class PrintScheduler:
|
|||
return
|
||||
|
||||
total_timeout = timeout + (phase_b_timeout if landed_on_subtask else 0.0)
|
||||
if revert_outcome == "command_rejected":
|
||||
logger.error(
|
||||
"Queue item %s: printer %d reported HMS %s (MQTT command verification "
|
||||
"failed) — the print command was rejected, not lost. Failing the item "
|
||||
"without retrying; enable Developer Mode on the printer and restart it (#2732)",
|
||||
queue_item_id,
|
||||
printer_id,
|
||||
HMS_MQTT_VERIFY_FAILED,
|
||||
)
|
||||
await scheduler._notify_dispatch_gave_up(
|
||||
queue_item_id,
|
||||
printer_id,
|
||||
created_by_id,
|
||||
reason="Printer rejected the print command (MQTT command verification failed)",
|
||||
)
|
||||
# Same reasoning as the landed_on_subtask path below: the file is on
|
||||
# the printer and a forced reconnect would only add 0500_4003 to a
|
||||
# problem that has nothing to do with the MQTT session (#1150).
|
||||
return
|
||||
if revert_outcome == "gave_up":
|
||||
logger.error(
|
||||
"Queue item %s: printer %d never started the print after %d dispatch "
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import socket
|
|||
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
|
||||
from backend.app.services.bambu_mqtt import CONNECT_ERROR_AUTH_REJECTED
|
||||
from backend.app.services.camera import get_camera_port
|
||||
from backend.app.services.discovery import is_running_in_docker
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
|
@ -56,6 +57,27 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
|
|||
return False
|
||||
|
||||
|
||||
# Public alias. The connection watchdog probes the MQTT port before rebuilding a
|
||||
# client, so it can tell "the printer is switched off" (leave it alone, paho will
|
||||
# keep retrying) from "the printer is answering but our session is dead" (#2732).
|
||||
check_port = _check_port
|
||||
|
||||
|
||||
def _auth_reason_params(reason: str | None) -> dict:
|
||||
"""Map a client's CONNACK-refusal slug onto the check's `params.reason`.
|
||||
|
||||
The frontend renders `diagnostic.check.<id>.<status>_<reason>` when a reason
|
||||
is present and falls back to the plain per-status text otherwise, so an
|
||||
unknown or absent slug degrades to today's generic wording rather than a
|
||||
missing string. Only `auth_rejected` currently carries its own message:
|
||||
that is the one case where the printer positively told us the credentials
|
||||
were wrong, as opposed to us merely observing that we are not connected.
|
||||
"""
|
||||
if reason == CONNECT_ERROR_AUTH_REJECTED:
|
||||
return {"reason": CONNECT_ERROR_AUTH_REJECTED}
|
||||
return {}
|
||||
|
||||
|
||||
def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
|
||||
"""Return the model-specific camera diagnostic port and display protocol."""
|
||||
if not printer:
|
||||
|
|
@ -249,14 +271,30 @@ async def run_connection_diagnostic(
|
|||
serial_number=serial_number,
|
||||
access_code=access_code,
|
||||
)
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if result.get("success") else "fail"))
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id="mqtt_auth",
|
||||
status="pass" if result.get("success") else "fail",
|
||||
params=_auth_reason_params(result.get("reason")),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("test_connection failed during diagnostic", exc_info=True)
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="fail"))
|
||||
elif state is not None:
|
||||
# Existing printer: trust the live MQTT state rather than opening a
|
||||
# second connection (Bambu printers tolerate few concurrent sessions).
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="pass" if state.connected else "fail"))
|
||||
# `connected == False` alone does not say *why* — the live client keeps
|
||||
# the last CONNACK refusal, so a rejected access code can be reported as
|
||||
# such instead of as a generic failure the user has to guess at (#2698).
|
||||
client = printer_manager.get_client(printer.id) if printer else None
|
||||
checks.append(
|
||||
DiagnosticCheck(
|
||||
id="mqtt_auth",
|
||||
status="pass" if state.connected else "fail",
|
||||
params={} if state.connected else _auth_reason_params(getattr(client, "last_connect_error", None)),
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(DiagnosticCheck(id="mqtt_auth", status="skip"))
|
||||
|
||||
|
|
|
|||
|
|
@ -325,6 +325,7 @@ class PrinterManager:
|
|||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._on_ams_change: Callable[[int, list], None] | None = None
|
||||
self._on_layer_change: Callable[[int, int], None] | None = None
|
||||
self._on_print_progress: Callable[[int, int], None] | None = None
|
||||
self._on_bed_temp_update: Callable[[int, float], None] | None = None
|
||||
self._on_drying_complete: Callable[[int, int], None] | None = None
|
||||
self._on_assignment_verified: Callable[[int, int, int, bool, dict], None] | None = None
|
||||
|
|
@ -377,6 +378,13 @@ class PrinterManager:
|
|||
UI without it. Centralised here so every current AND future caller is
|
||||
covered without each one having to remember to broadcast.
|
||||
"""
|
||||
# Callers re-assert the current value routinely (the queue clears the gate
|
||||
# on every dispatch, whether or not it was up), so the outward-facing
|
||||
# emissions below are edge-triggered — an MQTT subscriber or a phone
|
||||
# notification must not see a "plate cleared" for a plate that was never
|
||||
# dirty. Persistence and the WebSocket broadcast stay unconditional: they
|
||||
# are idempotent and predate this (#961/#1128).
|
||||
changed = awaiting != (printer_id in self._awaiting_plate_clear)
|
||||
if awaiting:
|
||||
self._awaiting_plate_clear.add(printer_id)
|
||||
else:
|
||||
|
|
@ -386,6 +394,45 @@ class PrinterManager:
|
|||
if self._loop and self._loop.is_running():
|
||||
self._schedule_async(self._persist_awaiting_plate_clear(printer_id, awaiting))
|
||||
self._schedule_async(self._broadcast_status_change(printer_id))
|
||||
if changed:
|
||||
self._schedule_async(self._emit_plate_clear_change(printer_id, awaiting))
|
||||
|
||||
async def _emit_plate_clear_change(self, printer_id: int, awaiting: bool) -> None:
|
||||
"""Relay a plate-clear gate transition to MQTT and notifications (#2525).
|
||||
|
||||
The flag is Bambuddy-side, so nothing about it reaches an external
|
||||
automation on its own — the printer's own MQTT push knows only
|
||||
RUNNING/PAUSE/FAILED/FINISH/IDLE. Emitted from here rather than from the
|
||||
three call sites so every current and future caller is covered, the same
|
||||
reasoning as the WebSocket broadcast above.
|
||||
|
||||
Imports are local: ``mqtt_relay`` and ``notification_service`` both sit
|
||||
above this module in the dependency order.
|
||||
"""
|
||||
printer = self.get_printer(printer_id)
|
||||
if not printer:
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
|
||||
await mqtt_relay.on_plate_clear_state(printer_id, printer.name, printer.serial_number, awaiting)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to publish plate-clear state for printer %d: %s", printer_id, e)
|
||||
|
||||
# Only the rising edge is worth a notification — "the bed is now free"
|
||||
# is not an action item, and the queue clears the gate by itself.
|
||||
if not awaiting:
|
||||
return
|
||||
|
||||
try:
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.services.notification_service import notification_service
|
||||
|
||||
async with async_session() as db:
|
||||
await notification_service.on_plate_clear_required(printer_id, printer.name, db)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send plate-clear notification for printer %d: %s", printer_id, e)
|
||||
|
||||
async def _broadcast_status_change(self, printer_id: int) -> None:
|
||||
"""Emit a ``printer_status`` WebSocket update for this printer (#1128).
|
||||
|
|
@ -502,6 +549,15 @@ class PrinterManager:
|
|||
"""Set callback for layer change events. Receives (printer_id, layer_num)."""
|
||||
self._on_layer_change = callback
|
||||
|
||||
def set_print_progress_callback(self, callback: Callable[[int, int], None]):
|
||||
"""Set callback for print-progress advances (#2547).
|
||||
|
||||
Receives (printer_id, percent) each time `mc_percent` increases during a
|
||||
running print — including the final layer, where layer-change events
|
||||
have already stopped.
|
||||
"""
|
||||
self._on_print_progress = callback
|
||||
|
||||
def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
|
||||
"""Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
|
||||
self._on_bed_temp_update = callback
|
||||
|
|
@ -578,6 +634,10 @@ class PrinterManager:
|
|||
if self._on_layer_change:
|
||||
self._schedule_async(self._on_layer_change(printer_id, layer_num))
|
||||
|
||||
def on_print_progress(percent: int):
|
||||
if self._on_print_progress:
|
||||
self._schedule_async(self._on_print_progress(printer_id, percent))
|
||||
|
||||
def on_bed_temp_update(bed_temp: float):
|
||||
if self._on_bed_temp_update:
|
||||
self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
|
||||
|
|
@ -600,6 +660,7 @@ class PrinterManager:
|
|||
on_print_complete=on_print_complete,
|
||||
on_ams_change=on_ams_change,
|
||||
on_layer_change=on_layer_change,
|
||||
on_print_progress=on_print_progress,
|
||||
on_bed_temp_update=on_bed_temp_update,
|
||||
on_drying_complete=on_drying_complete,
|
||||
on_print_running_observed=on_print_running_observed,
|
||||
|
|
@ -905,6 +966,11 @@ class PrinterManager:
|
|||
"success": client.state.connected,
|
||||
"state": client.state.state if client.state.connected else None,
|
||||
"model": client.state.raw_data.get("device_model"),
|
||||
# Why the probe failed, when the printer told us: one of the
|
||||
# CONNECT_ERROR_* slugs, else None. Lets the add-printer flow
|
||||
# and the connection diagnostic say "the printer rejected the
|
||||
# access code" instead of an unqualified failure (#2698).
|
||||
"reason": None if client.state.connected else client.last_connect_error,
|
||||
}
|
||||
finally:
|
||||
# Off-loop teardown — see docstring. paho's loop_stop() joins the
|
||||
|
|
@ -1154,6 +1220,13 @@ def printer_state_to_dict(
|
|||
"drying_temp": tray.get("drying_temp"),
|
||||
"drying_time": tray.get("drying_time"),
|
||||
"state": state_val,
|
||||
# Firmware's authoritative presence bit (tray_exist_bits),
|
||||
# set by apply_tray_exist_bits. The REST serializer already
|
||||
# emits it (routes/printers.py); without it here the WS
|
||||
# shallow-merge drops `exists` after the first frame and
|
||||
# getEmptySlotKind falls back to the firmware-variant state
|
||||
# 9/10 heuristic — wrong for AMS-HT in both directions (#2670).
|
||||
"exists": tray.get("exists"),
|
||||
}
|
||||
)
|
||||
# Prefer humidity_raw (actual percentage) over humidity (index 1-5)
|
||||
|
|
@ -1360,6 +1433,8 @@ def printer_state_to_dict(
|
|||
"big_fan1_speed": state.big_fan1_speed,
|
||||
"big_fan2_speed": state.big_fan2_speed,
|
||||
"heatbreak_fan_speed": state.heatbreak_fan_speed,
|
||||
"left_aux_fan_speed": state.left_aux_fan_speed,
|
||||
"exhaust_fan_present": state.exhaust_fan_present,
|
||||
# Chamber light state
|
||||
"chamber_light": state.chamber_light,
|
||||
# Active extruder for dual-nozzle printers (0=right, 1=left)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
"""Service for controlling smart plugs via generic REST/HTTP API."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -24,18 +22,39 @@ class RESTSmartPlugService:
|
|||
self.timeout = timeout
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> bool:
|
||||
"""Block cloud metadata and link-local IPs."""
|
||||
def _url_error(url: str) -> str | None:
|
||||
"""Return why *url* is rejected by the LAN-service policy, else None.
|
||||
|
||||
Split out from ``_validate_url`` so ``test_connection`` can tell the
|
||||
user which rule the URL broke instead of a single fixed sentence.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import assert_safe_lan_service_url
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
return not addr.is_loopback and not addr.is_link_local
|
||||
except ValueError:
|
||||
# Hostname is not an IP (e.g., "openhab.local") — allow it
|
||||
return True
|
||||
assert_safe_lan_service_url(url, label="REST plug URL")
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _validate_url(url: str) -> bool:
|
||||
"""Apply the shared LAN-service SSRF policy to a REST plug URL.
|
||||
|
||||
Delegates to ``_url_safety.assert_safe_lan_service_url`` — the same
|
||||
guard Spoolman, the notification providers and the LAN-service
|
||||
settings use — rather than reimplementing a narrower check. The
|
||||
hand-rolled version this replaces got the policy wrong in both
|
||||
directions: it rejected a literal ``127.0.0.1`` (so an openHAB or
|
||||
Node-RED instance on the same host could only be reached by spelling
|
||||
it ``localhost``), while allowing every target the shared policy
|
||||
rejects unconditionally — Alibaba/AWS-IPv6 metadata endpoints,
|
||||
numeric-encoded IPs, multicast and the unspecified address — because
|
||||
anything that wasn't a bare IP literal fell through to ``True``.
|
||||
|
||||
Loopback and RFC-1918 stay permitted on purpose: a REST-controlled
|
||||
plug bridge running next to Bambuddy is the normal topology.
|
||||
"""
|
||||
return RESTSmartPlugService._url_error(url) is None
|
||||
|
||||
def _parse_headers(self, headers_json: str | None) -> dict[str, str]:
|
||||
"""Parse JSON string to dict of headers."""
|
||||
|
|
@ -273,8 +292,9 @@ class RESTSmartPlugService:
|
|||
- success: bool
|
||||
- error: error message if failed
|
||||
"""
|
||||
if not self._validate_url(url):
|
||||
return {"success": False, "error": "Invalid URL (loopback/link-local addresses are blocked)"}
|
||||
url_error = self._url_error(url)
|
||||
if url_error:
|
||||
return {"success": False, "error": url_error}
|
||||
|
||||
parsed_headers = self._parse_headers(headers)
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ async def get_preview_filaments(
|
|||
file_name: str,
|
||||
api_url: str,
|
||||
request_id: str | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> list[dict] | None:
|
||||
"""Run a preview slice for ``plate_id``, parse the resulting slice_info,
|
||||
and return the per-plate filament list.
|
||||
|
|
@ -92,7 +93,11 @@ async def get_preview_filaments(
|
|||
return cached
|
||||
|
||||
try:
|
||||
async with SlicerApiService(base_url=api_url) as svc:
|
||||
# Preview slices are bounded the same way as real ones (#2730):
|
||||
# a heavy plate can take a long time and must not be cut off
|
||||
# while the slicer is visibly working.
|
||||
svc_kwargs = {} if timeout_seconds is None else {"timeout_seconds": timeout_seconds}
|
||||
async with SlicerApiService(base_url=api_url, **svc_kwargs) as svc:
|
||||
result = await svc.slice_without_profiles(
|
||||
model_bytes=file_bytes,
|
||||
model_filename=file_name,
|
||||
|
|
|
|||
|
|
@ -264,14 +264,27 @@ def substitute_unused_plate_filaments(source_3mf_bytes: bytes, plate_id: int | N
|
|||
doesn't even use.
|
||||
|
||||
The substitution is a no-op when:
|
||||
- ``plate_id`` is None (we can't determine which slots are unused),
|
||||
- ``plate_id`` is not a real plate — ``None`` (caller couldn't say) or
|
||||
``0`` (the slice-all sentinel, where every slot is used by *some*
|
||||
plate so there is nothing unused to substitute). Callers that know
|
||||
"absent means plate 1" must resolve that themselves before calling;
|
||||
this function will not guess, because guessing wrong rewrites a
|
||||
filament the plate actually prints with.
|
||||
- the source isn't a valid 3MF / zip,
|
||||
- the source doesn't carry plate-extruder metadata (parse returns
|
||||
empty set — treat as "every slot is used", same fallback the
|
||||
SliceModal uses),
|
||||
- ``items`` has fewer than 2 entries (nothing to substitute).
|
||||
|
||||
The ``0`` guard is load-bearing rather than cosmetic. Plate ids are
|
||||
1-indexed, so the geometry lookup for plate 0 matches nothing — but
|
||||
the support-filament slots unioned in below come from the project
|
||||
config and carry no plate scope at all. Without the guard a slice-all
|
||||
of a project with a dedicated support slot would see ``used`` as just
|
||||
that one slot, anchor on it, and rewrite every colour in the project
|
||||
to the support material (#2711).
|
||||
"""
|
||||
if plate_id is None or len(items) < 2:
|
||||
if plate_id is None or plate_id < 1 or len(items) < 2:
|
||||
return items
|
||||
# Local import keeps the bytes->ZipFile boundary in this module and
|
||||
# avoids dragging zipfile into every caller.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ under the hood, response body is raw G-code or 3MF with metadata in the
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
import zipfile
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
|
|
@ -38,6 +41,18 @@ class SlicerInputError(SlicerApiError):
|
|||
"""Sidecar rejected the input as invalid (4xx)."""
|
||||
|
||||
|
||||
class SlicerTimeoutError(SlicerApiError):
|
||||
"""We gave up waiting on a slice that never finished.
|
||||
|
||||
Kept apart from ``SlicerApiUnavailableError`` because they call for
|
||||
opposite reactions and used to be reported as the same thing: an
|
||||
``httpx.ReadTimeout`` is a subclass of ``RequestError``, so a slice that
|
||||
simply took a long time surfaced as "Slicer sidecar unreachable" — sending
|
||||
the reporter of #2730 off to check a sidecar that was reachable throughout
|
||||
and still slicing when we hung up on it.
|
||||
"""
|
||||
|
||||
|
||||
class SliceResult(NamedTuple):
|
||||
"""Result of a slice operation."""
|
||||
|
||||
|
|
@ -49,6 +64,36 @@ class SliceResult(NamedTuple):
|
|||
|
||||
_shared_http_client: httpx.AsyncClient | None = None
|
||||
|
||||
# Fallback for callers that don't pass one (tests, and any path that runs
|
||||
# without a DB session to read the setting from). The user-facing value is
|
||||
# ``slicer_stall_timeout_minutes`` under Settings -> Workflow -> Slicer.
|
||||
DEFAULT_SLICE_STALL_TIMEOUT_SECONDS = 15 * 60.0
|
||||
|
||||
# How often the progress poller ticks. Also the granularity of the stall check,
|
||||
# since a missed tick is what the stall clock is counting.
|
||||
_PROGRESS_POLL_INTERVAL = 1.0
|
||||
|
||||
|
||||
async def get_stall_timeout_seconds(db) -> float:
|
||||
"""Read ``slicer_stall_timeout_minutes`` (Settings -> Workflow -> Slicer).
|
||||
|
||||
Falls back to the default on anything unparseable rather than failing the
|
||||
slice — a bad settings row must not be the reason a print doesn't happen.
|
||||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
try:
|
||||
raw = await get_setting(db, "slicer_stall_timeout_minutes")
|
||||
except Exception:
|
||||
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
|
||||
try:
|
||||
minutes = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
|
||||
if minutes < 1:
|
||||
return DEFAULT_SLICE_STALL_TIMEOUT_SECONDS
|
||||
return float(minutes) * 60.0
|
||||
|
||||
|
||||
def _format_sidecar_error(response: httpx.Response) -> str:
|
||||
"""Build a human-readable error string from a sidecar 4xx/5xx response.
|
||||
|
|
@ -74,6 +119,62 @@ def _format_sidecar_error(response: httpx.Response) -> str:
|
|||
return (message or details or response.text)[:500]
|
||||
|
||||
|
||||
def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
|
||||
"""Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
|
||||
|
||||
Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
|
||||
handling and output validation live in one place.
|
||||
|
||||
Beyond the status check, this guards against the sidecar (or a reverse proxy
|
||||
in front of it) returning **HTTP 200 with a body that isn't a real slice**
|
||||
(#2671): a stock/misconfigured sidecar, a proxy interstitial or truncated
|
||||
response, or an OrcaSlicer/BambuStudio CLI crash that produces empty output.
|
||||
Without this check Bambuddy would store that tiny blob as a ``.gcode.3mf``,
|
||||
let it be queued, and FTP it to the printer — a silently-broken print. When
|
||||
a 3MF export was requested the body must be a valid ZIP (3MF container);
|
||||
anything else is treated as a sidecar failure.
|
||||
|
||||
Raises:
|
||||
SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
|
||||
SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
|
||||
"""
|
||||
if response.status_code == 413:
|
||||
# A 413 almost never comes from the slicer itself — it's a reverse proxy
|
||||
# (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
|
||||
# profiles). Name the real fix so the user doesn't tweak the wrong layer.
|
||||
raise SlicerInputError(
|
||||
"The slice request was rejected as too large (HTTP 413). A reverse proxy "
|
||||
"in front of the slicer sidecar is capping the request body — raise "
|
||||
"'client_max_body_size' (nginx/SWAG) or the equivalent on the proxy that "
|
||||
"sits directly in front of the sidecar, then reload it. If the sidecar is "
|
||||
"behind Cloudflare, note its request-size cap."
|
||||
)
|
||||
if response.status_code >= 500:
|
||||
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
if response.status_code >= 400:
|
||||
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
|
||||
content = response.content
|
||||
if export_3mf and not zipfile.is_zipfile(io.BytesIO(content)):
|
||||
# 200 OK but the body is not a 3MF zip → the sidecar did not produce a
|
||||
# usable slice. Surface it loudly instead of persisting a corrupt file.
|
||||
detail = _format_sidecar_error(response) if len(content) <= 500 else ""
|
||||
raise SlicerApiServerError(
|
||||
f"Slicer sidecar returned HTTP {response.status_code} but the body is not a valid "
|
||||
f"3MF ({len(content)} bytes). This usually means a misconfigured sidecar, an "
|
||||
f"OrcaSlicer/BambuStudio CLI crash producing no output, or a reverse proxy returning "
|
||||
f"an error page or truncating the response — verify the sidecar URL and any proxy in "
|
||||
f"front of it." + (f" Body: {detail}" if detail else "")
|
||||
)
|
||||
|
||||
return SliceResult(
|
||||
content=content,
|
||||
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
|
||||
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
|
||||
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
|
||||
)
|
||||
|
||||
|
||||
def set_shared_http_client(client: httpx.AsyncClient | None) -> None:
|
||||
"""Register an app-scoped client so per-request services can pool transport."""
|
||||
global _shared_http_client
|
||||
|
|
@ -91,6 +192,65 @@ def _guess_model_content_type(filename: str) -> str:
|
|||
return "application/octet-stream"
|
||||
|
||||
|
||||
class _Liveness:
|
||||
"""Tracks when the slicer last showed a sign of life.
|
||||
|
||||
``deadline`` is what the slice waits against, and it moves forward on every
|
||||
genuine progress update. A slice therefore fails only after the configured
|
||||
window of *silence*, however long the whole thing has been running (#2730).
|
||||
|
||||
``progress_supported`` stays False for sidecars that never answer the
|
||||
progress endpoint. Those give us nothing to judge liveness by, so the caller
|
||||
treats the same window as a total-elapsed ceiling rather than pretending a
|
||||
stall can be detected.
|
||||
"""
|
||||
|
||||
def __init__(self, window_seconds: float, poll_interval: float = _PROGRESS_POLL_INTERVAL) -> None:
|
||||
# Liveness can only be observed as often as the poller ticks, so a
|
||||
# window shorter than a few ticks would expire in the gap between two
|
||||
# polls and fail every slice instantly, however healthy. The settings
|
||||
# schema already floors the user-facing value at a minute; this guards
|
||||
# the constructor, which tests and any future caller can pass anything.
|
||||
self.window_seconds = max(window_seconds, poll_interval * 3)
|
||||
self.progress_supported = False
|
||||
self.started_at = time.monotonic()
|
||||
self._last_alive = self.started_at
|
||||
|
||||
def saw_progress_endpoint(self) -> None:
|
||||
self.progress_supported = True
|
||||
|
||||
def mark_alive(self) -> None:
|
||||
self._last_alive = time.monotonic()
|
||||
|
||||
@property
|
||||
def deadline(self) -> float:
|
||||
"""Monotonic time at which we stop waiting."""
|
||||
base = self._last_alive if self.progress_supported else self.started_at
|
||||
return base + self.window_seconds
|
||||
|
||||
def silent_for(self) -> float:
|
||||
return time.monotonic() - self._last_alive
|
||||
|
||||
def elapsed(self) -> float:
|
||||
return time.monotonic() - self.started_at
|
||||
|
||||
def timeout_message(self) -> str:
|
||||
minutes = self.window_seconds / 60
|
||||
if self.progress_supported:
|
||||
return (
|
||||
f"The slicer stopped reporting progress for {minutes:.0f} minutes "
|
||||
f"(slicing had been running for {self.elapsed() / 60:.0f} minutes). "
|
||||
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer if this model "
|
||||
"legitimately needs longer between progress updates."
|
||||
)
|
||||
return (
|
||||
f"Slicing did not finish within {minutes:.0f} minutes, and this sidecar does not "
|
||||
"report progress, so there was no way to tell a slow model from a stalled one. "
|
||||
"Raise 'Slicer stall timeout' under Settings -> Workflow -> Slicer, or update the "
|
||||
"sidecar to a version that reports progress."
|
||||
)
|
||||
|
||||
|
||||
class SlicerApiService:
|
||||
"""Talks to an OrcaSlicer / BambuStudio API sidecar."""
|
||||
|
||||
|
|
@ -99,10 +259,25 @@ class SlicerApiService:
|
|||
base_url: str,
|
||||
*,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
timeout_seconds: float = 300.0,
|
||||
timeout_seconds: float = DEFAULT_SLICE_STALL_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
"""``timeout_seconds`` bounds *silence*, not total slicing time (#2730).
|
||||
|
||||
While a slice is running Bambuddy polls the sidecar's progress channel
|
||||
once a second, so it can tell a model that is merely slow from one that
|
||||
has stopped: the clock is reset by every progress update, and only runs
|
||||
out when the slicer has said nothing for this long. A heavy model that
|
||||
keeps reporting will run to completion however long it takes.
|
||||
|
||||
Sidecars too old to report progress have no liveness signal to offer, so
|
||||
for those the same number bounds total elapsed time — the pre-#2730
|
||||
behaviour, but configurable and no longer five minutes flat.
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout_seconds = timeout_seconds
|
||||
# Instance-level so tests can compress the timing; production always
|
||||
# uses the module default.
|
||||
self.progress_poll_interval = _PROGRESS_POLL_INTERVAL
|
||||
if client is not None:
|
||||
self._client = client
|
||||
self._owns_client = False
|
||||
|
|
@ -159,6 +334,8 @@ class SlicerApiService:
|
|||
self,
|
||||
request_id: str,
|
||||
on_progress: Callable[[dict], None],
|
||||
*,
|
||||
liveness: "_Liveness | None" = None,
|
||||
) -> None:
|
||||
"""Poll the sidecar's progress endpoint at ~1Hz and forward each
|
||||
snapshot to ``on_progress``. Runs until cancelled.
|
||||
|
|
@ -174,14 +351,27 @@ class SlicerApiService:
|
|||
slice grace expiry) just costs a few wasted GETs that the cancel
|
||||
will stop. Network errors and non-JSON 5xx are swallowed; the
|
||||
next tick retries.
|
||||
|
||||
When ``liveness`` is supplied this doubles as the stall watchdog: every
|
||||
200 carrying a *changed* payload marks the slicer alive, which is what
|
||||
keeps the slice's deadline moving (#2730). An unchanged payload
|
||||
deliberately does not count — the sidecar re-serves its last snapshot on
|
||||
every poll, so treating a repeat as progress would leave the watchdog
|
||||
unable to detect a stall at all.
|
||||
"""
|
||||
url = f"{self.base_url}/slice/progress/{request_id}"
|
||||
last_payload: dict | None = None
|
||||
while True:
|
||||
try:
|
||||
response = await self._client.get(url, timeout=5.0)
|
||||
if response.status_code == 200:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict):
|
||||
if liveness is not None:
|
||||
liveness.saw_progress_endpoint()
|
||||
if payload != last_payload:
|
||||
liveness.mark_alive()
|
||||
last_payload = payload
|
||||
on_progress(payload)
|
||||
# 404 / other 4xx = no progress available (yet, or ever
|
||||
# for older sidecars). Keep polling — the outer slice
|
||||
|
|
@ -191,10 +381,85 @@ class SlicerApiService:
|
|||
# returns a non-JSON 5xx. Don't crash the poller.
|
||||
pass
|
||||
try:
|
||||
await asyncio.sleep(1.0)
|
||||
await asyncio.sleep(self.progress_poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
|
||||
async def _post_slice(
|
||||
self,
|
||||
*,
|
||||
files: list | dict,
|
||||
data: dict,
|
||||
request_id: str | None,
|
||||
on_progress: Callable[[dict], None] | None,
|
||||
) -> httpx.Response:
|
||||
"""POST /slice, supervised by the progress channel rather than a clock.
|
||||
|
||||
Before #2730 this was a plain ``httpx`` call with a flat 300 s timeout on
|
||||
every phase. A genuinely heavy model — the reporter's was a MakerWorld
|
||||
model that Bambu Studio also took a long time over — hit the ceiling
|
||||
while it was still slicing perfectly happily, and because
|
||||
``httpx.ReadTimeout`` is a ``RequestError`` it was reported as "Slicer
|
||||
sidecar unreachable". Meanwhile Bambuddy was polling the sidecar's
|
||||
progress endpoint once a second and could see the thing working.
|
||||
|
||||
So the read timeout comes off the HTTP call and the poller supervises
|
||||
instead: the deadline is pushed forward by every progress update, and
|
||||
only a genuine silence ends the wait. Connect and pool keep short
|
||||
timeouts — a sidecar that won't accept the connection at all is
|
||||
unreachable, and should still say so quickly.
|
||||
"""
|
||||
liveness = _Liveness(self.timeout_seconds, self.progress_poll_interval)
|
||||
|
||||
# Poll whenever we have a request_id, even if the caller wants no
|
||||
# progress callbacks: the poll is what makes stall detection possible,
|
||||
# and one GET per second is cheaper than a wrongly-cancelled slice.
|
||||
progress_task: asyncio.Task | None = None
|
||||
if request_id is not None:
|
||||
progress_task = asyncio.create_task(
|
||||
self._poll_progress(request_id, on_progress or (lambda _payload: None), liveness=liveness),
|
||||
name=f"slicer-progress-{request_id}",
|
||||
)
|
||||
|
||||
post_task = asyncio.create_task(
|
||||
self._client.post(
|
||||
f"{self.base_url}/slice",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0),
|
||||
),
|
||||
name="slicer-slice-post",
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
remaining = liveness.deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
post_task.cancel()
|
||||
logger.warning(
|
||||
"Slice abandoned after %.0fs (silent for %.0fs, progress channel %s)",
|
||||
liveness.elapsed(),
|
||||
liveness.silent_for(),
|
||||
"available" if liveness.progress_supported else "unavailable",
|
||||
)
|
||||
raise SlicerTimeoutError(liveness.timeout_message())
|
||||
# Re-check at poll granularity so a progress update that lands
|
||||
# mid-wait extends the deadline promptly.
|
||||
done, _pending = await asyncio.wait({post_task}, timeout=min(remaining, self.progress_poll_interval))
|
||||
if post_task in done:
|
||||
break
|
||||
finally:
|
||||
if progress_task is not None:
|
||||
progress_task.cancel()
|
||||
# Await both so neither is left pending — a cancelled POST still
|
||||
# needs its connection released back to the pool.
|
||||
await asyncio.gather(post_task, progress_task or asyncio.sleep(0), return_exceptions=True)
|
||||
|
||||
try:
|
||||
return post_task.result()
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
|
||||
async def slice_with_profiles(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -270,41 +535,8 @@ class SlicerApiService:
|
|||
# and surfaces structured updates via on_progress. Uses a
|
||||
# short-tick poll (1s) since the slicer emits stage changes
|
||||
# several times per minute on complex models.
|
||||
progress_task: asyncio.Task | None = None
|
||||
if request_id is not None and on_progress is not None:
|
||||
progress_task = asyncio.create_task(
|
||||
self._poll_progress(request_id, on_progress),
|
||||
name=f"slicer-progress-{request_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/slice",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
finally:
|
||||
if progress_task is not None:
|
||||
progress_task.cancel()
|
||||
try:
|
||||
await progress_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass # Polling errors must not fail the slice.
|
||||
|
||||
if response.status_code >= 500:
|
||||
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
if response.status_code >= 400:
|
||||
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
|
||||
return SliceResult(
|
||||
content=response.content,
|
||||
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
|
||||
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
|
||||
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
|
||||
)
|
||||
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf)
|
||||
|
||||
async def slice_without_profiles(
|
||||
self,
|
||||
|
|
@ -348,41 +580,8 @@ class SlicerApiService:
|
|||
# embedded-settings fallback path triggered by an Orca/Bambu CLI
|
||||
# segfault on complex H2D models — both want to keep updating
|
||||
# the user's toast through the slow operation.
|
||||
progress_task: asyncio.Task | None = None
|
||||
if request_id is not None and on_progress is not None:
|
||||
progress_task = asyncio.create_task(
|
||||
self._poll_progress(request_id, on_progress),
|
||||
name=f"slicer-progress-{request_id}",
|
||||
)
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
f"{self.base_url}/slice",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=self.timeout_seconds,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
finally:
|
||||
if progress_task is not None:
|
||||
progress_task.cancel()
|
||||
try:
|
||||
await progress_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
if response.status_code >= 500:
|
||||
raise SlicerApiServerError(f"Slicer CLI failed ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
if response.status_code >= 400:
|
||||
raise SlicerInputError(f"Slicer rejected input ({response.status_code}): {_format_sidecar_error(response)}")
|
||||
|
||||
return SliceResult(
|
||||
content=response.content,
|
||||
print_time_seconds=_safe_int(response.headers.get("x-print-time-seconds")),
|
||||
filament_used_g=_safe_float(response.headers.get("x-filament-used-g")),
|
||||
filament_used_mm=_safe_float(response.headers.get("x-filament-used-mm")),
|
||||
)
|
||||
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf)
|
||||
|
||||
|
||||
def _safe_int(value: str | None) -> int:
|
||||
|
|
|
|||
|
|
@ -26,12 +26,32 @@ class TasmotaService:
|
|||
|
||||
@staticmethod
|
||||
def _validate_ip(ip: str) -> bool:
|
||||
"""Block cloud metadata and link-local IPs."""
|
||||
"""Block cloud metadata, loopback and link-local destinations.
|
||||
|
||||
Deliberately stricter than the shared LAN-service guard, and kept that
|
||||
way: a Tasmota plug is always a separate device on the LAN, so a bare
|
||||
IP literal is the only sensible value. Anything that is not one —
|
||||
including a symbolic hostname — still fails closed here, which is why
|
||||
this does not simply delegate to ``assert_safe_lan_service_url``.
|
||||
|
||||
What it borrows from the shared guard is the destination set that is
|
||||
dangerous under any topology: cloud-metadata endpoints beyond the AWS
|
||||
IPv4 address (Alibaba's 100.100.100.200, AWS's fd00:ec2::254),
|
||||
multicast and unspecified addresses, and IPv4-mapped IPv6 encodings
|
||||
used to smuggle any of the above past the per-class checks.
|
||||
"""
|
||||
from backend.app.api.routes._url_safety import CLOUD_METADATA_IPS, unwrap_ipv4_mapped
|
||||
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except ValueError:
|
||||
return False # Not a valid IP
|
||||
return not addr.is_loopback and not addr.is_link_local
|
||||
effective = unwrap_ipv4_mapped(addr)
|
||||
if effective in CLOUD_METADATA_IPS:
|
||||
return False
|
||||
if effective.is_multicast or effective.is_unspecified:
|
||||
return False
|
||||
return not effective.is_loopback and not effective.is_link_local
|
||||
|
||||
async def _send_command(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
|
|||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
|
@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
|
|||
return None
|
||||
|
||||
|
||||
def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
|
||||
"""Pull the slicer's own AMS-slot pick out of a captured project_file payload.
|
||||
|
||||
BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
|
||||
live, right before sending — either automatically or via the slicer's
|
||||
manual per-filament AMS-slot assignment dialog — and embeds the result as
|
||||
``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
|
||||
ID) directly in the MQTT ``project_file`` command. Confirmed by wire
|
||||
capture: the field is present and already in the exact shape
|
||||
``PrintQueueItem.ams_mapping`` expects.
|
||||
|
||||
The VP-queue path previously never read this — every queued print had the
|
||||
scheduler re-derive a mapping from just the 3MF's static type/color at
|
||||
dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
|
||||
the slicer's already-correct, live-resolved pick. That re-derivation can
|
||||
land on the wrong physical spool whenever the file's type+color match
|
||||
isn't unique (e.g. two spools of the same color) or the file's own
|
||||
filament-slot color wasn't what the user actually intended for that
|
||||
particular print. Capturing it here — mirroring the existing
|
||||
``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
|
||||
scheduler's "already resolved, don't touch it" branch in
|
||||
``_ensure_ams_mapping`` use the slicer's own choice unchanged.
|
||||
|
||||
That branch skipping ``_compute_ams_mapping_for_printer`` is also what
|
||||
makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
|
||||
AMS-filament-backup gate (#1766), the inventory-remain overrides and the
|
||||
per-slot force-color overrides all live inside that function. Callers are
|
||||
responsible for the gating — this parser only says what the slicer sent.
|
||||
|
||||
Returns ``None`` when the field is absent, unparsable, or the classic
|
||||
"all -1" unresolved-race sentinel (#2589) — never worth trusting over a
|
||||
fresh live computation.
|
||||
"""
|
||||
raw = data.get("ams_mapping")
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
|
||||
return None
|
||||
# bool is a subclass of int in Python — isinstance(True, int) is True —
|
||||
# so it must be excluded explicitly, or [True, False] would pass as a
|
||||
# valid mapping.
|
||||
if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
|
||||
return None
|
||||
if all(v < 0 for v in raw):
|
||||
# #2589 sentinel — every slot unresolved. Let the scheduler compute a
|
||||
# fresh mapping from live AMS state instead of trusting this.
|
||||
return None
|
||||
return json.dumps(raw)
|
||||
|
||||
|
||||
def _get_serial_for_model(model: str, serial_suffix: str) -> str:
|
||||
"""Get serial number for the given model and suffix."""
|
||||
prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
|
||||
|
|
@ -181,6 +236,7 @@ class VirtualPrinterInstance:
|
|||
target_printer_id: int | None = None,
|
||||
auto_dispatch: bool = True,
|
||||
queue_force_color_match: bool = False,
|
||||
save_ams_mapping: bool = False,
|
||||
gcode_injection: bool = False,
|
||||
bind_ip: str = "",
|
||||
remote_interface_ip: str = "",
|
||||
|
|
@ -204,6 +260,7 @@ class VirtualPrinterInstance:
|
|||
self.target_printer_id = target_printer_id
|
||||
self.auto_dispatch = auto_dispatch
|
||||
self.queue_force_color_match = queue_force_color_match
|
||||
self.save_ams_mapping = save_ams_mapping
|
||||
self.gcode_injection = gcode_injection
|
||||
self.bind_ip = bind_ip
|
||||
self.remote_interface_ip = remote_interface_ip
|
||||
|
|
@ -416,8 +473,9 @@ class VirtualPrinterInstance:
|
|||
row was already written with settings defaults. This method runs
|
||||
on the late MQTT path: it looks up the most recent queue items
|
||||
committed for this filename and patches in the slicer's
|
||||
``nozzle_mapping`` + workflow flags, but only while the items are
|
||||
still ``pending`` (scheduler hasn't dispatched them yet).
|
||||
``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
|
||||
while the items are still ``pending`` (scheduler hasn't dispatched
|
||||
them yet).
|
||||
"""
|
||||
if not self._session_factory:
|
||||
return
|
||||
|
|
@ -469,12 +527,34 @@ class VirtualPrinterInstance:
|
|||
if raw is not None:
|
||||
patch["nozzle_mapping"] = json.dumps(raw)
|
||||
|
||||
if not patch:
|
||||
# Same two gates as the immediate path in `_add_to_print_queue`: a
|
||||
# model-based VP has no live AMS layout for the slicer to have resolved
|
||||
# tray IDs against, and taking the slicer's pick at all is the per-VP
|
||||
# `save_ams_mapping` opt-in (it makes the scheduler skip
|
||||
# `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
|
||||
# #1766 backup gate).
|
||||
ams_mapping_json = (
|
||||
_extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
|
||||
if self.target_printer_id is not None and self.save_ams_mapping
|
||||
else None
|
||||
)
|
||||
# `Force color match` still wins for this dispatch — see the same
|
||||
# decision in `_add_to_print_queue`. The archive patch below is
|
||||
# deliberately not gated on it: persisting the pick for later reprints
|
||||
# is exactly what the toggle promises.
|
||||
if ams_mapping_json is not None and not self.queue_force_color_match:
|
||||
patch["ams_mapping"] = ams_mapping_json
|
||||
|
||||
# `ams_mapping_json` alone is enough to keep going even when `patch` is
|
||||
# empty: with `Force color match` on it never reaches the queue item,
|
||||
# but it still has to be written onto the archive below.
|
||||
if not patch and ams_mapping_json is None:
|
||||
self._recent_queue_items.pop(stash_key, None)
|
||||
return
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
try:
|
||||
|
|
@ -482,23 +562,49 @@ class VirtualPrinterInstance:
|
|||
# Only stamp items still pending; once the scheduler has
|
||||
# picked the row up we can't safely race the dispatcher.
|
||||
result = await db.execute(
|
||||
select(PrintQueueItem.id).where(
|
||||
select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
|
||||
PrintQueueItem.id.in_(queue_item_ids),
|
||||
PrintQueueItem.status == "pending",
|
||||
)
|
||||
)
|
||||
eligible_ids = [row[0] for row in result.all()]
|
||||
rows = result.all()
|
||||
eligible_ids = [row[0] for row in rows]
|
||||
if not eligible_ids:
|
||||
self._recent_queue_items.pop(stash_key, None)
|
||||
return
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
|
||||
if patch:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
|
||||
|
||||
# The archive was already created (with no slicer_ams_mapping)
|
||||
# before this late MQTT arrived — see
|
||||
# `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
|
||||
# too so a reprint later still picks up the slicer's pick, and
|
||||
# the "AMS mapping from slicer" badge reflects reality instead
|
||||
# of staying stuck on the archive's initial (empty) snapshot.
|
||||
# Already gated on `save_ams_mapping` above, and deliberately
|
||||
# NOT on `queue_force_color_match`: that toggle decides how
|
||||
# *this* print is matched, not whether the pick is worth
|
||||
# keeping for a later reprint.
|
||||
if ams_mapping_json is not None:
|
||||
archive_ids = {row[1] for row in rows if row[1] is not None}
|
||||
if archive_ids:
|
||||
archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
|
||||
for archive in archive_result.scalars().all():
|
||||
extra = dict(archive.extra_data or {})
|
||||
extra["slicer_ams_mapping"] = {
|
||||
"mapping": json.loads(ams_mapping_json),
|
||||
"printer_id": self.target_printer_id,
|
||||
}
|
||||
archive.extra_data = extra
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
|
||||
"[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
|
||||
self.name,
|
||||
stash_key,
|
||||
sorted(patch.keys()),
|
||||
eligible_ids,
|
||||
" and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -834,6 +940,60 @@ class VirtualPrinterInstance:
|
|||
if raw is not None:
|
||||
nozzle_mapping_json = json.dumps(raw)
|
||||
|
||||
# Slicer's own live-resolved AMS-slot pick (see docstring on
|
||||
# `_extract_slicer_ams_mapping_json`). Stamped onto every plate
|
||||
# below, same treatment as nozzle_mapping_json above — when
|
||||
# present it makes `_ensure_ams_mapping` skip its own
|
||||
# type/color re-derivation entirely and dispatch use exactly
|
||||
# the tray the slicer/user picked.
|
||||
#
|
||||
# Two gates, both required:
|
||||
#
|
||||
# 1. This VP must target one fixed printer. A model-based
|
||||
# ("Any <model>") VP has no MQTT bridge to a real printer,
|
||||
# so the slicer has no live AMS layout to resolve tray IDs
|
||||
# against — whatever it sends here is meaningless (or,
|
||||
# worse, coincidentally valid for the wrong printer once
|
||||
# the scheduler later picks one).
|
||||
# 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
|
||||
# the slicer's pick means `_ensure_ams_mapping` returns
|
||||
# early and `_compute_ams_mapping_for_printer` never runs —
|
||||
# and that function is where `prefer_lowest_filament`, its
|
||||
# AMS-filament-backup gate (#1766) and the inventory-remain
|
||||
# overrides live. Honouring the slicer unconditionally would
|
||||
# silently retire all of that for every existing queue-mode
|
||||
# VP on upgrade, so it's opt-in like every other queue-mode
|
||||
# behaviour toggle (#2700 review).
|
||||
#
|
||||
# Either gate failing leaves it unset, and the scheduler's
|
||||
# normal type/color re-derivation runs against whichever
|
||||
# printer actually gets the job.
|
||||
ams_mapping_json: str | None = None
|
||||
if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
|
||||
ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
|
||||
|
||||
# `Force color match` is the user asking Bambuddy to do the
|
||||
# matching strictly, against the printer's live trays. Its only
|
||||
# effect on a fixed-printer item is via the per-slot
|
||||
# `filament_overrides` written below, which are consumed inside
|
||||
# `_compute_ams_mapping_for_printer` — the exact function a
|
||||
# stored mapping skips. So when both toggles are on, the
|
||||
# explicit strictness wins for *this* dispatch and the slicer's
|
||||
# pick is still persisted onto the archive for later reprints,
|
||||
# which is what `Save AMS mapping` actually promises (#2700
|
||||
# review).
|
||||
queue_ams_mapping_json = ams_mapping_json
|
||||
if queue_ams_mapping_json is not None and self.queue_force_color_match:
|
||||
logger.info(
|
||||
"[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
|
||||
"'Force color match' is on, so the scheduler matches against live trays for this print.",
|
||||
self.name,
|
||||
)
|
||||
queue_ams_mapping_json = None
|
||||
|
||||
# Parsed once for the per-plate length check in the loop below.
|
||||
queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
|
||||
|
||||
service = ArchiveService(db)
|
||||
archive = await service.archive_print(
|
||||
printer_id=None,
|
||||
|
|
@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
|
|||
"source_ip": source_ip,
|
||||
},
|
||||
prefer_filename_for_name=prefer_filename,
|
||||
# Slicer's own live AMS-slot pick -- promoted to
|
||||
# `extra_data.slicer_ams_mapping` by archive_print() so a
|
||||
# later reprint can reuse it. Already gated on the per-VP
|
||||
# `save_ams_mapping` opt-in above. Tagged with the printer
|
||||
# it was resolved against so a later reprint on a
|
||||
# *different* printer knows not to reuse it (#2700 review).
|
||||
slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
|
||||
slicer_ams_mapping_printer_id=self.target_printer_id,
|
||||
)
|
||||
if archive:
|
||||
logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
|
||||
|
|
@ -903,11 +1071,20 @@ class VirtualPrinterInstance:
|
|||
if types:
|
||||
required_filament_types_json = json.dumps(types)
|
||||
if self.queue_force_color_match:
|
||||
# Carry tray_info_idx so force_color_match can
|
||||
# tell Bambu PLA variants apart (#2650). Bambu
|
||||
# reports Basic/Matte/Silk all as tray_type
|
||||
# "PLA"; the variant lives only in tray_info_idx
|
||||
# (GFA00/GFA01/GFA06/...). A blank idx (custom or
|
||||
# third-party spool) means "no variant
|
||||
# constraint" and the scheduler falls back to
|
||||
# type+colour.
|
||||
overrides = [
|
||||
{
|
||||
"slot_id": r["slot_id"],
|
||||
"type": r.get("type", ""),
|
||||
"color": r.get("color", ""),
|
||||
"tray_info_idx": r.get("tray_info_idx", ""),
|
||||
"force_color_match": True,
|
||||
}
|
||||
for r in requirements
|
||||
|
|
@ -916,6 +1093,31 @@ class VirtualPrinterInstance:
|
|||
if overrides:
|
||||
filament_overrides_json = json.dumps(overrides)
|
||||
|
||||
# The slicer's mapping is indexed by the 3MF's own
|
||||
# file-global slot ids (position = slot_id - 1), so one
|
||||
# array covers every plate of a multi-plate Send All —
|
||||
# each plate just reads the entries for the slots it
|
||||
# actually prints. What must be checked is that it
|
||||
# reaches that far: a mapping shorter than this plate's
|
||||
# highest slot id can't address the plate's own slots,
|
||||
# and `_ensure_ams_mapping` would keep it anyway
|
||||
# because it only rejects an all-unresolved mapping. Fall
|
||||
# back to a computed mapping for that plate instead
|
||||
# (#2700 review).
|
||||
plate_ams_mapping_json = queue_ams_mapping_json
|
||||
if queue_ams_mapping is not None and requirements:
|
||||
max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
|
||||
if max_slot_id > len(queue_ams_mapping):
|
||||
logger.warning(
|
||||
"[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
|
||||
"dropping it for this plate so the scheduler computes one from live AMS state.",
|
||||
self.name,
|
||||
len(queue_ams_mapping),
|
||||
plate_id,
|
||||
max_slot_id,
|
||||
)
|
||||
plate_ams_mapping_json = None
|
||||
|
||||
queue_item = PrintQueueItem(
|
||||
printer_id=self.target_printer_id,
|
||||
target_model=target_model,
|
||||
|
|
@ -941,6 +1143,9 @@ class VirtualPrinterInstance:
|
|||
# the same nozzle pick across plates rather than only the
|
||||
# first one (mirrors the #1697 / #1188 per-plate loop fix).
|
||||
nozzle_mapping=nozzle_mapping_json,
|
||||
# Slicer's own live AMS-slot pick, when present —
|
||||
# see `_extract_slicer_ams_mapping_json`.
|
||||
ams_mapping=plate_ams_mapping_json,
|
||||
)
|
||||
db.add(queue_item)
|
||||
await db.flush() # populate queue_item.id before logging
|
||||
|
|
@ -1538,6 +1743,7 @@ class VirtualPrinterManager:
|
|||
# instance silently keeps the old value until process
|
||||
# restart (#1552 follow-up family).
|
||||
or instance.queue_force_color_match != vp.queue_force_color_match
|
||||
or instance.save_ams_mapping != vp.save_ams_mapping
|
||||
or instance.gcode_injection != vp.gcode_injection
|
||||
or proxy_target_changed
|
||||
)
|
||||
|
|
@ -1592,6 +1798,7 @@ class VirtualPrinterManager:
|
|||
target_printer_id=vp.target_printer_id,
|
||||
auto_dispatch=vp.auto_dispatch,
|
||||
queue_force_color_match=vp.queue_force_color_match,
|
||||
save_ams_mapping=vp.save_ams_mapping,
|
||||
gcode_injection=vp.gcode_injection,
|
||||
bind_ip=vp.bind_ip or "",
|
||||
remote_interface_ip=vp.remote_interface_ip or "",
|
||||
|
|
|
|||
|
|
@ -659,6 +659,12 @@ class MQTTBridge:
|
|||
# paints those empty slots as phantom loaded filaments (#1726).
|
||||
# Runs whether or not a prev cache existed — fresh pushalls also
|
||||
# carry tray_exist_bits and benefit from the cleanup.
|
||||
# These units carry the RAW firmware ids — this cache is what the
|
||||
# slicer sees, and BambuStudio addresses the A2L's AMS-Lite as the
|
||||
# physical id 16 (it sends `ams_get_rfid {ams_id: 16}` through the
|
||||
# VP), so we must not normalise them to 6 the way Bambuddy's
|
||||
# internal state does. `apply_tray_exist_bits` folds 16 onto the
|
||||
# same bit base internally instead (#2697).
|
||||
merged_ams_dict = new_state.get("ams")
|
||||
if isinstance(merged_ams_dict, dict):
|
||||
units = merged_ams_dict.get("ams")
|
||||
|
|
|
|||
|
|
@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# Models sold with a single nozzle flow variant, so a Standard / High Flow
|
||||
# choice on a K-profile is meaningless there. Derived from the slicer's own
|
||||
# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
|
||||
# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
|
||||
# single-nozzle and all carry two variants. Only the A-series has one.
|
||||
SINGLE_NOZZLE_FLOW_MODELS = frozenset(
|
||||
[
|
||||
# Display names (uppercase, no spaces)
|
||||
"A1",
|
||||
"A1MINI",
|
||||
"A2L",
|
||||
# Internal codes
|
||||
"N1", # A1 Mini
|
||||
"N2S", # A1
|
||||
"N9", # A2L
|
||||
"A04", # A1 Mini (alternate)
|
||||
"A11", # A1
|
||||
"A12", # A1 Mini
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# Models without any external storage (MicroSD / SD card slot).
|
||||
# The A1 and A1 Mini ship with internal storage only — there is no
|
||||
# firmware-side "Store sent files on external storage" toggle and no
|
||||
|
|
@ -212,6 +234,36 @@ DUAL_NOZZLE_MODELS = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# Models where Bambu's own firmware/UI names the enclosure fan (big_fan2 /
|
||||
# airduct part id 3) "Exhaust" rather than "Chamber". On these the printer's
|
||||
# touchscreen and Bambu Studio both call it the exhaust fan, and on the P2S it
|
||||
# is an add-on kit rather than built-in hardware. Other enclosed models
|
||||
# (X1 / P1S / H2 series) keep the "Chamber" naming.
|
||||
EXHAUST_FAN_LABEL_MODELS = frozenset(
|
||||
[
|
||||
# Display names (uppercase, no spaces)
|
||||
"P2S",
|
||||
"X2D",
|
||||
# Internal codes
|
||||
"N7", # P2S
|
||||
"N6", # X2D
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def uses_exhaust_fan_label(model: str | None) -> bool:
|
||||
"""Return True if this model calls the big_fan2 enclosure fan "Exhaust".
|
||||
|
||||
P2S/X2D name that fan "Exhaust" in Bambu's firmware/UI; everything else
|
||||
enclosed calls it the chamber fan. Used so the UI badge and the API
|
||||
response message agree on what the user sees.
|
||||
"""
|
||||
if not model:
|
||||
return False
|
||||
normalized = model.strip().upper().replace(" ", "").replace("-", "")
|
||||
return normalized in EXHAUST_FAN_LABEL_MODELS
|
||||
|
||||
|
||||
def has_ethernet(model: str | None) -> bool:
|
||||
"""Return True if the printer model has an ethernet port."""
|
||||
if not model:
|
||||
|
|
@ -260,6 +312,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
|
|||
return normalized in DUAL_NOZZLE_MODELS
|
||||
|
||||
|
||||
def supports_nozzle_flow_type(model: str | None) -> bool:
|
||||
"""Return True if the model offers a Standard / High Flow nozzle choice.
|
||||
|
||||
A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
|
||||
(Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
|
||||
profile's identity on any printer where both exist — and meaningless noise
|
||||
on one where only a single variant is sold.
|
||||
|
||||
The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
|
||||
and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
|
||||
from the machine preset — ``support_nozzle_volume()`` is
|
||||
``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
|
||||
Bambu profile evaluated against that formula puts only the A-series on the
|
||||
"one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
|
||||
upward at 2 or more per extruder).
|
||||
|
||||
Defaults to True for unknown models: offering the choice on a printer that
|
||||
turns out to have one flow type costs the user a redundant dropdown, while
|
||||
hiding it on one that has two makes half its calibration table
|
||||
unreachable.
|
||||
"""
|
||||
if not model:
|
||||
return True
|
||||
normalized = model.strip().upper().replace(" ", "").replace("-", "")
|
||||
return normalized not in SINGLE_NOZZLE_FLOW_MODELS
|
||||
|
||||
|
||||
def get_rod_type(model: str | None) -> str | None:
|
||||
"""Return the rod/rail type for a printer model.
|
||||
|
||||
|
|
|
|||
|
|
@ -702,6 +702,68 @@ def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
|
|||
return header
|
||||
|
||||
|
||||
def _select_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
|
||||
"""Pick a plate's ``.gcode`` member out of a 3MF namelist.
|
||||
|
||||
Prefers ``plate_<id>.gcode``, then falls back to the first ``.gcode``
|
||||
member so single-plate files — and files from slicers that don't use the
|
||||
plate naming convention — still resolve.
|
||||
"""
|
||||
gcodes = [n for n in names if n.endswith(".gcode")]
|
||||
if not gcodes:
|
||||
return None
|
||||
if plate_id is not None:
|
||||
suffix = f"plate_{plate_id}.gcode"
|
||||
for name in gcodes:
|
||||
if name.endswith(suffix):
|
||||
return name
|
||||
return gcodes[0]
|
||||
|
||||
|
||||
# The header block sits at the very top of the plate G-code. Read only that
|
||||
# much: a sliced plate is routinely tens of megabytes and `ZipFile.read()`
|
||||
# would inflate all of it to reach ~40 lines.
|
||||
_HEADER_READ_LIMIT_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def extract_max_z_height_from_3mf(file_path: Path, plate_id: int | None = None) -> float | None:
|
||||
"""Return the plate's ``max_z_height`` in mm, or None if not knowable.
|
||||
|
||||
This is the Z the toolhead sat at for the final layer — the same value
|
||||
Bambu's own end G-code adds its bed-drop offset to (``G1 Z{max_layer_z +
|
||||
100}``). #2547 uses it to put the plate back into camera framing before the
|
||||
finish photo, which is only safe because it is a height the printer was
|
||||
physically at seconds earlier.
|
||||
|
||||
None means "don't know" and callers must treat it as such rather than
|
||||
substituting a default: the file may be unreadable, carry no plate G-code,
|
||||
or come from a slicer that writes no ``max_z_height`` header. Guessing a
|
||||
height here would command a Z move to somewhere the nozzle has never been.
|
||||
"""
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
target = _select_plate_gcode_name(zf.namelist(), plate_id)
|
||||
if target is None:
|
||||
return None
|
||||
with zf.open(target, "r") as fh:
|
||||
head = fh.read(_HEADER_READ_LIMIT_BYTES)
|
||||
except (OSError, zipfile.BadZipFile, KeyError) as e:
|
||||
logger.debug("max_z_height: cannot read %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
raw = _parse_3mf_gcode_header(head.decode("utf-8", errors="ignore")).get("max_z_height")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
logger.debug("max_z_height: unusable value %r in %s", raw, file_path)
|
||||
return None
|
||||
# Zero or negative means the header key is present but meaningless. Passed
|
||||
# on as a height it would become a move *toward* the bed, so drop it.
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _substitute_placeholders(snippet: str, header: dict[str, str]) -> str:
|
||||
"""Replace `{var}` placeholders with header values, leaving unknowns intact."""
|
||||
|
||||
|
|
@ -802,21 +864,10 @@ def inject_gcode_into_3mf(
|
|||
try:
|
||||
# Find the target gcode file inside the 3MF
|
||||
with zipfile.ZipFile(source_path, "r") as zf:
|
||||
all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
|
||||
if not all_gcode:
|
||||
return None
|
||||
|
||||
# Try plate-specific gcode file first
|
||||
target_gcode = None
|
||||
plate_pattern = f"plate_{plate_id}.gcode"
|
||||
for f in all_gcode:
|
||||
if f.endswith(plate_pattern):
|
||||
target_gcode = f
|
||||
break
|
||||
|
||||
# Fall back to first gcode file
|
||||
# Plate-specific gcode first, else the first one in the file.
|
||||
target_gcode = _select_plate_gcode_name(zf.namelist(), plate_id)
|
||||
if target_gcode is None:
|
||||
target_gcode = all_gcode[0]
|
||||
return None
|
||||
|
||||
# Read and modify gcode content
|
||||
gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
|
||||
|
|
@ -907,6 +958,51 @@ def extract_project_filaments_from_3mf(zf: zipfile.ZipFile) -> list[dict]:
|
|||
return out
|
||||
|
||||
|
||||
def expand_to_project_slots(zf: zipfile.ZipFile, used: list[dict]) -> list[dict]:
|
||||
"""Widen a used-only filament list to one entry per project slot.
|
||||
|
||||
``used`` is the slice_info-derived list: only the slots whose G-code
|
||||
actually consumed filament, each carrying real usage figures. That is the
|
||||
right answer for print-time AMS matching, and the wrong one for the slice
|
||||
modal, because the list the modal builds is **positional** — index 0 is
|
||||
slot 1 all the way down to the ``filament_N.json`` parts handed to the CLI.
|
||||
A source whose only used slot is 4 therefore produced a single dropdown
|
||||
whose pick the CLI bound to slot 1, leaving slot 4 — the one the model
|
||||
prints with — on whatever the source had baked in (#2712).
|
||||
|
||||
Returns the project's slots in slot order, each flagged ``used_in_plate``.
|
||||
Rows present in ``used`` are kept whole, so their usage figures, resolved
|
||||
type/colour and ``tray_info_idx`` survive; the rest come from the project
|
||||
configuration with zero usage. A used slot beyond the project's slot count
|
||||
is appended rather than dropped — the caller asked for a superset, and
|
||||
silently losing the one slot that prints would be the original bug again.
|
||||
|
||||
``used`` is returned unchanged when the file carries no project settings
|
||||
to widen against: a narrower-than-ideal list still prints correctly, an
|
||||
invented one might not.
|
||||
"""
|
||||
project = extract_project_filaments_from_3mf(zf)
|
||||
if not project:
|
||||
return used
|
||||
|
||||
by_slot = {f["slot_id"]: f for f in used}
|
||||
out: list[dict] = []
|
||||
for slot in project:
|
||||
known = by_slot.pop(slot["slot_id"], None)
|
||||
if known is not None:
|
||||
known["used_in_plate"] = True
|
||||
out.append(known)
|
||||
else:
|
||||
slot["used_in_plate"] = False
|
||||
out.append(slot)
|
||||
# Anything slice_info reported that the project doesn't declare.
|
||||
for leftover in by_slot.values():
|
||||
leftover["used_in_plate"] = True
|
||||
out.append(leftover)
|
||||
out.sort(key=lambda f: f["slot_id"])
|
||||
return out
|
||||
|
||||
|
||||
def extract_support_filament_slots_from_3mf(zf: zipfile.ZipFile) -> set[int]:
|
||||
"""Slots referenced by the process settings for support material.
|
||||
|
||||
|
|
|
|||
|
|
@ -203,8 +203,17 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
|
|||
test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async def override_get_db():
|
||||
# Mirror production get_db (core/database.py): commit on success,
|
||||
# rollback on error. Endpoints that rely on the request-scoped
|
||||
# implicit commit (e.g. create_project, which only flushes) would
|
||||
# otherwise silently lose their writes in tests (#1897).
|
||||
async with test_async_session() as session:
|
||||
yield session
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except BaseException:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
|
||||
|
|
@ -217,6 +226,9 @@ async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, N
|
|||
patch("backend.app.core.database.async_session", test_async_session),
|
||||
patch("backend.app.core.auth.async_session", test_async_session),
|
||||
patch("backend.app.main.async_session", test_async_session),
|
||||
# Obico endpoints load settings through the service's module-level binding;
|
||||
# without this patch they'd read whatever DB the cwd resolves to (#1546).
|
||||
patch("backend.app.services.obico_detection.async_session", test_async_session),
|
||||
patch("backend.app.main.init_printer_connections", mock_init_printer_connections),
|
||||
):
|
||||
# Seed default groups for tests that need them
|
||||
|
|
|
|||
|
|
@ -1022,6 +1022,29 @@ class TestArchivesSlimAPI:
|
|||
assert "duplicates" not in item
|
||||
assert "duplicate_count" not in item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_includes_energy_fields(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Per-print smart-plug energy surfaces through /slim so the stats
|
||||
page can include it in cost records and trends (#1432)."""
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
status="completed",
|
||||
cost=1.50,
|
||||
energy_kwh=0.421,
|
||||
energy_cost=0.063,
|
||||
)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/slim")
|
||||
|
||||
assert response.status_code == 200
|
||||
item = response.json()[0]
|
||||
assert item["energy_kwh"] == 0.421
|
||||
assert item["energy_cost"] == 0.063
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_computes_actual_time(
|
||||
|
|
@ -1720,3 +1743,95 @@ class TestUploadSourceThreeMF:
|
|||
assert "outside the data directory" in response.json()["detail"]
|
||||
# Did not write anything under the bogus /tmp/source/ either.
|
||||
assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists() # nosec B108
|
||||
|
||||
|
||||
class TestSoftDeletedArchivesAreExcluded:
|
||||
"""Soft-deleted archives (#1343) must not leak into export or analysis (#2731).
|
||||
|
||||
The soft delete keeps the row so global Quick Stats can still count it, but
|
||||
the archive is gone from every listing. Two consumers never got the memo:
|
||||
the CSV export handed back rows the UI says do not exist, and per-project
|
||||
failure analysis kept counting prints the user had deleted from the project
|
||||
— disagreeing with the project's own figures.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def _soft_delete(db_session, archive) -> int:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
archive_id = archive.id
|
||||
archive.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
return archive_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_export_omits_soft_deleted_archives(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
printer = await printer_factory()
|
||||
await archive_factory(printer.id, print_name="Kept Print")
|
||||
gone = await archive_factory(printer.id, print_name="Deleted Print")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/export?format=csv")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "Kept Print" in body
|
||||
assert "Deleted Print" not in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_project_failure_analysis_omits_soft_deleted_archives(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
from backend.app.models.project import Project
|
||||
|
||||
project = Project(name="Analysis Project")
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
project_id = project.id
|
||||
|
||||
printer = await printer_factory()
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
print_name="Kept Failure",
|
||||
status="failed",
|
||||
failure_reason="bed_adhesion",
|
||||
project_id=project_id,
|
||||
)
|
||||
gone = await archive_factory(
|
||||
printer.id,
|
||||
print_name="Deleted Failure",
|
||||
status="failed",
|
||||
failure_reason="filament_runout",
|
||||
project_id=project_id,
|
||||
)
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/analysis/failures?project_id={project_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["failed_prints"] == 1
|
||||
assert result["failures_by_reason"] == {"bed_adhesion": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unscoped_failure_analysis_is_unchanged(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session
|
||||
):
|
||||
"""Only the project-scoped path filters. Global analysis still counts
|
||||
every run, including orphans, exactly as #1390 established."""
|
||||
printer = await printer_factory()
|
||||
gone = await archive_factory(
|
||||
printer.id, print_name="Deleted Failure", status="failed", failure_reason="filament_runout"
|
||||
)
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get("/api/v1/archives/analysis/failures")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["failed_prints"] == 1
|
||||
|
|
|
|||
|
|
@ -501,6 +501,12 @@ class TestCloudRouteRegionPlumbing:
|
|||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
captured.append(str(request.url))
|
||||
# The TOTP path now performs a CSRF handshake first (#2696): it
|
||||
# fetches /api/csrf and refuses to submit the code unless that call
|
||||
# yields a bbl_csrf_token cookie. Mint one here so region-routing
|
||||
# tests reach the TFA POST they are actually asserting on.
|
||||
if request.url.path == "/api/csrf":
|
||||
return httpx.Response(204, headers={"set-cookie": "bbl_csrf_token=csrf-test-token; Path=/"})
|
||||
return httpx.Response(status, json=response_json)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
|
|
@ -574,6 +580,10 @@ class TestCloudRouteRegionPlumbing:
|
|||
# TOTP endpoint lives on bambulab.cn (without the api. prefix),
|
||||
# NOT bambulab.com — that's exactly the bug we just fixed.
|
||||
assert any("bambulab.cn/api/sign-in/tfa" in url for url in captured_urls), captured_urls
|
||||
# The CSRF handshake (#2696) must follow the same origin —
|
||||
# fetching a token from the global site would hand the .cn
|
||||
# endpoint a cookie it never issued.
|
||||
assert any("bambulab.cn/api/csrf" in url for url in captured_urls), captured_urls
|
||||
assert not any("bambulab.com" in url for url in captured_urls), captured_urls
|
||||
finally:
|
||||
set_shared_http_client(None)
|
||||
|
|
|
|||
100
backend/tests/integration/test_design_settings_plates.py
Normal file
100
backend/tests/integration/test_design_settings_plates.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""The plates endpoints must surface the designer's changed settings (#2622).
|
||||
|
||||
Parsing is covered in ``unit/test_design_settings.py``. What is asserted here is
|
||||
the wiring: SliceModal reads ``design_overrides`` off the plates response, so a
|
||||
correct parser that never reaches the payload is a feature that silently does
|
||||
nothing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
def _designed_3mf(path: Path, *, with_deviations: bool = True) -> None:
|
||||
"""A Bambu-style project 3MF, optionally carrying designer deviations."""
|
||||
config = {
|
||||
"print_settings_id": "0.20mm Standard @BBL A1",
|
||||
"printer_settings_id": "Bambu Lab A1 0.4 nozzle",
|
||||
"filament_settings_id": ["Bambu PLA Basic @BBL A1"],
|
||||
"wall_loops": "5",
|
||||
"outer_wall_speed": "200",
|
||||
"machine_start_gcode": "G28 ; designer printer",
|
||||
"different_settings_to_system": (
|
||||
["wall_loops;outer_wall_speed", "", "machine_start_gcode"] if with_deviations else ["", "", ""]
|
||||
),
|
||||
}
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("Metadata/plate_1.gcode", "G0\n")
|
||||
zf.writestr("Metadata/project_settings.config", json.dumps(config))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _patch_base_dir(monkeypatch, tmp_path):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "base_dir", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestArchivePlatesDesignOverrides:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_returns_the_process_deviations_with_classification(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
|
||||
):
|
||||
_designed_3mf(_patch_base_dir / "designed.3mf")
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id, filename="designed.3mf", file_path="designed.3mf")
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
|
||||
|
||||
assert response.status_code == 200
|
||||
overrides = response.json()["design_overrides"]
|
||||
assert [o["key"] for o in overrides] == ["outer_wall_speed", "wall_loops"]
|
||||
by_key = {o["key"]: o for o in overrides}
|
||||
assert by_key["wall_loops"] == {"key": "wall_loops", "value": "5", "printer_coupled": False}
|
||||
assert by_key["outer_wall_speed"]["printer_coupled"] is True
|
||||
# The printer slot must never leak into the process list.
|
||||
assert "machine_start_gcode" not in by_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_for_a_file_that_changes_nothing(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, _patch_base_dir
|
||||
):
|
||||
_designed_3mf(_patch_base_dir / "stock.3mf", with_deviations=False)
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(printer.id, filename="stock.3mf", file_path="stock.3mf")
|
||||
|
||||
response = await async_client.get(f"/api/v1/archives/{archive.id}/plates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["design_overrides"] == []
|
||||
|
||||
|
||||
class TestLibraryPlatesDesignOverrides:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_returns_the_process_deviations(self, async_client: AsyncClient, db_session, tmp_path):
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
path = tmp_path / "designed.3mf"
|
||||
_designed_3mf(path)
|
||||
lib_file = LibraryFile(
|
||||
filename="designed.3mf",
|
||||
file_path=str(path),
|
||||
file_type="3mf",
|
||||
file_size=path.stat().st_size,
|
||||
)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
|
||||
response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/plates")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [o["key"] for o in response.json()["design_overrides"]] == ["outer_wall_speed", "wall_loops"]
|
||||
|
|
@ -542,6 +542,144 @@ class TestExternalFolderScan:
|
|||
assert subfolder["external_readonly"] is True
|
||||
|
||||
|
||||
class TestExternalFolderModifiedTime:
|
||||
"""Filesystem mtime capture + recursive activity sort (#2680).
|
||||
|
||||
The folder tree's "sort by recent activity" and the file pane's date sort
|
||||
must track the real on-disk mtime (``ls -t``), not the DB ``updated_at`` (the
|
||||
scan instant, identical across a bulk scan).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _set_mtime(path: Path, epoch: float) -> None:
|
||||
os.utime(path, (epoch, epoch))
|
||||
|
||||
@pytest.fixture
|
||||
async def make_folder(self, async_client, db_session):
|
||||
async def _make(ext_dir: Path, name: str = "MTime Test") -> dict:
|
||||
data = {
|
||||
"name": name,
|
||||
"external_path": str(ext_dir),
|
||||
"readonly": True,
|
||||
"show_hidden": False,
|
||||
}
|
||||
resp = await async_client.post("/api/v1/library/folders/external", json=data)
|
||||
assert resp.status_code == 200
|
||||
return resp.json()
|
||||
|
||||
return _make
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scan_captures_file_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
|
||||
"""Each scanned file carries its real on-disk mtime, not the scan time."""
|
||||
ext = tmp_path / "prints"
|
||||
ext.mkdir()
|
||||
old = ext / "old.3mf"
|
||||
new = ext / "new.3mf"
|
||||
old.write_bytes(b"a")
|
||||
new.write_bytes(b"b")
|
||||
# old.3mf modified 2021-01-01, new.3mf modified 2024-01-01.
|
||||
self._set_mtime(old, 1609459200.0) # 2021-01-01T00:00:00Z
|
||||
self._set_mtime(new, 1704067200.0) # 2024-01-01T00:00:00Z
|
||||
|
||||
folder = await make_folder(ext)
|
||||
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
||||
|
||||
resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
|
||||
files = {f["filename"]: f for f in resp.json()}
|
||||
assert files["old.3mf"]["fs_modified_at"] is not None
|
||||
assert files["new.3mf"]["fs_modified_at"] is not None
|
||||
# The real mtime, not "now": the 2021 file must predate the 2024 file.
|
||||
assert files["old.3mf"]["fs_modified_at"] < files["new.3mf"]["fs_modified_at"]
|
||||
assert files["old.3mf"]["fs_modified_at"].startswith("2021")
|
||||
assert files["new.3mf"]["fs_modified_at"].startswith("2024")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_rescan_refreshes_changed_file_mtime(self, async_client, db_session, tmp_path, make_folder):
|
||||
"""A file edited over the mount re-sorts on the next scan (#2680)."""
|
||||
ext = tmp_path / "prints"
|
||||
ext.mkdir()
|
||||
f = ext / "part.3mf"
|
||||
f.write_bytes(b"a")
|
||||
self._set_mtime(f, 1609459200.0) # 2021
|
||||
|
||||
folder = await make_folder(ext)
|
||||
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
||||
|
||||
# File touched later (samba edit); re-scan must pick up the new mtime.
|
||||
self._set_mtime(f, 1704067200.0) # 2024
|
||||
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
||||
|
||||
resp = await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")
|
||||
got = resp.json()[0]
|
||||
assert got["fs_modified_at"].startswith("2024")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_recursive_activity_bubbles_deep_file_to_root(self, async_client, db_session, tmp_path, make_folder):
|
||||
"""A freshly-added deep file lifts every ancestor's activity (#2680).
|
||||
|
||||
``a`` holds only an OLD file directly but a NEW file three levels down;
|
||||
``b`` holds a MIDDLE-aged file directly. Recursive bubble must rank ``a``
|
||||
(newest descendant) ahead of ``b`` even though a's own direct file and
|
||||
directory are older.
|
||||
"""
|
||||
root = tmp_path / "root"
|
||||
deep = root / "a" / "x" / "y"
|
||||
deep.mkdir(parents=True)
|
||||
(root / "b").mkdir()
|
||||
|
||||
a_direct = root / "a" / "shallow.3mf"
|
||||
deep_file = deep / "deep.3mf"
|
||||
b_direct = root / "b" / "mid.3mf"
|
||||
for p, data in ((a_direct, b"1"), (deep_file, b"2"), (b_direct, b"3")):
|
||||
p.write_bytes(data)
|
||||
|
||||
self._set_mtime(a_direct, 1609459200.0) # 2021 (oldest)
|
||||
self._set_mtime(b_direct, 1656633600.0) # 2022-07 (middle)
|
||||
self._set_mtime(deep_file, 1704067200.0) # 2024 (newest, deep under a)
|
||||
# Directory mtimes are all old so only the deep FILE can lift branch a.
|
||||
for d in (root, root / "a", root / "a" / "x", deep, root / "b"):
|
||||
self._set_mtime(d, 1609459200.0)
|
||||
|
||||
folder = await make_folder(root)
|
||||
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
||||
|
||||
tree = (await async_client.get("/api/v1/library/folders")).json()
|
||||
top = find_folder_in_tree(tree, folder["name"])
|
||||
assert top is not None
|
||||
children = {c["name"]: c for c in top["children"]}
|
||||
assert "a" in children and "b" in children
|
||||
# Branch a's activity == the deep 2024 file; b's == its 2022 file.
|
||||
assert children["a"]["latest_activity_at"] > children["b"]["latest_activity_at"]
|
||||
assert children["a"]["latest_activity_at"].startswith("2024")
|
||||
# The root itself bubbles up to the newest descendant anywhere inside it.
|
||||
assert top["latest_activity_at"].startswith("2024")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scan_captures_folder_fs_mtime(self, async_client, db_session, tmp_path, make_folder):
|
||||
"""An empty-but-recently-touched subfolder still carries a real mtime."""
|
||||
root = tmp_path / "root"
|
||||
sub = root / "sub"
|
||||
sub.mkdir(parents=True)
|
||||
# A file so the subfolder survives the empty-subfolder cleanup.
|
||||
(sub / "keep.3mf").write_bytes(b"a")
|
||||
self._set_mtime(sub / "keep.3mf", 1609459200.0) # 2021
|
||||
self._set_mtime(sub, 1704067200.0) # dir touched 2024
|
||||
|
||||
folder = await make_folder(root)
|
||||
await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
|
||||
|
||||
tree = (await async_client.get("/api/v1/library/folders")).json()
|
||||
subfolder = find_folder_in_tree(tree, "sub")
|
||||
assert subfolder is not None
|
||||
# Dir mtime (2024) beats the single 2021 file → folder activity is 2024.
|
||||
assert subfolder["latest_activity_at"].startswith("2024")
|
||||
|
||||
|
||||
class TestExternalFolderProtections:
|
||||
"""Tests for read-only protections on external folders."""
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,17 @@ def _install_mock_sidecar(handler: Callable[[httpx.Request], httpx.Response]) ->
|
|||
return client
|
||||
|
||||
|
||||
def _is_slice_post(request: httpx.Request) -> bool:
|
||||
"""True for the slice call itself, false for the progress polls beside it.
|
||||
|
||||
Since #2730 a slice is supervised by a 1 Hz poll of
|
||||
``GET /slice/progress/{id}``, which shares this mock transport. Tests that
|
||||
count *slice attempts* — primary vs embedded-settings fallback — have to
|
||||
exclude those, or the count becomes a measure of how long the test took.
|
||||
"""
|
||||
return request.method == "POST" and request.url.path.endswith("/slice")
|
||||
|
||||
|
||||
async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0) -> dict:
|
||||
"""Poll `/api/v1/slice-jobs/{id}` until the job hits a terminal state.
|
||||
|
||||
|
|
@ -93,15 +104,24 @@ async def _wait_for_job(client: AsyncClient, job_id: int, timeout: float = 5.0)
|
|||
|
||||
|
||||
@pytest.fixture
|
||||
async def slice_test_setup(db_session, tmp_path):
|
||||
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer."""
|
||||
async def slice_test_setup(db_session, tmp_path, monkeypatch):
|
||||
"""Source LibraryFile + 3 LocalPresets + preferred_slicer=orcaslicer.
|
||||
|
||||
``base_dir`` is patched via ``monkeypatch`` rather than assigned and
|
||||
restored by hand. ``app_settings`` is a process-wide singleton, and the
|
||||
hand-rolled version only restored after the ``yield`` — so anything raising
|
||||
during setup (a commit, a refresh) left ``base_dir`` pointing at a
|
||||
``tmp_path`` that pytest then deleted, and every later test in that xdist
|
||||
worker which reads it failed. That was the cause of intermittent failures
|
||||
in ``TestLibraryPathHelpers`` and ``TestArchivePlatesDesignOverrides``,
|
||||
which share nothing with this module but land in the same worker.
|
||||
"""
|
||||
storage_dir = tmp_path / "library" / "files"
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
src_path = storage_dir / "Cube.stl"
|
||||
src_path.write_bytes(b"solid Cube\nendsolid\n")
|
||||
|
||||
original_base_dir = app_settings.base_dir
|
||||
app_settings.base_dir = tmp_path
|
||||
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
|
||||
|
||||
src_file = LibraryFile(
|
||||
filename="Cube.stl",
|
||||
|
|
@ -137,7 +157,6 @@ async def slice_test_setup(db_session, tmp_path):
|
|||
"tmp_path": tmp_path,
|
||||
}
|
||||
|
||||
app_settings.base_dir = original_base_dir
|
||||
slicer_api_module.set_shared_http_client(None)
|
||||
|
||||
|
||||
|
|
@ -206,7 +225,7 @@ class TestSliceLibraryFile:
|
|||
captured["url"] = str(request.url)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "656",
|
||||
"x-filament-used-g": "0.94",
|
||||
|
|
@ -249,7 +268,7 @@ class TestSliceLibraryFile:
|
|||
captured["body"] = bytes(request.content)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "10",
|
||||
"x-filament-used-g": "0.1",
|
||||
|
|
@ -291,7 +310,7 @@ class TestSliceLibraryFile:
|
|||
captured["body"] = bytes(request.content)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "10",
|
||||
"x-filament-used-g": "0.1",
|
||||
|
|
@ -406,6 +425,8 @@ class TestSliceLibraryFile:
|
|||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
call_count["n"] += 1
|
||||
# First call: profile triplet present → simulate CLI 5xx
|
||||
if call_count["n"] == 1:
|
||||
|
|
@ -416,7 +437,7 @@ class TestSliceLibraryFile:
|
|||
# Retry: no profile triplet → succeed with embedded settings
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "100",
|
||||
"x-filament-used-g": "1.0",
|
||||
|
|
@ -446,7 +467,9 @@ class TestSliceLibraryFile:
|
|||
# STL has no embedded settings — the CLI 5xx is terminal.
|
||||
call_count = {"n": 0}
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
call_count["n"] += 1
|
||||
return httpx.Response(
|
||||
status_code=500,
|
||||
|
|
@ -499,7 +522,7 @@ class TestSliceLibraryFile:
|
|||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "1",
|
||||
"x-filament-used-g": "0",
|
||||
|
|
@ -560,11 +583,13 @@ class TestSliceLibraryFile:
|
|||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
call_count["n"] += 1
|
||||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "100",
|
||||
"x-filament-used-g": "1.0",
|
||||
|
|
@ -607,7 +632,7 @@ class TestSliceLibraryFile:
|
|||
captured["body"] = request.content
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=b"PK\x03\x04 fake-3mf",
|
||||
content=_make_3mf_with_settings(), # #2671: real zip; validation rejects non-3MF bodies
|
||||
headers={
|
||||
"x-print-time-seconds": "1",
|
||||
"x-filament-used-g": "0",
|
||||
|
|
@ -769,6 +794,8 @@ class TestCrossClassSliceAllLoop:
|
|||
captured_requests: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
# Multipart bodies aren't trivially parseable here; pull
|
||||
# the plate field by string search since the helper sends
|
||||
# ``name="plate"`` immediately followed by the value.
|
||||
|
|
@ -1455,6 +1482,8 @@ class TestSliceSlicerRejection:
|
|||
call_count = {"n": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
call_count["n"] += 1
|
||||
return httpx.Response(
|
||||
status_code=500,
|
||||
|
|
@ -1618,3 +1647,356 @@ class TestNozzleClassGuard:
|
|||
if resp.status_code == 400:
|
||||
detail = resp.json().get("detail", "")
|
||||
assert "isn't supported" not in detail, f"guard still firing on preset path: {detail!r}"
|
||||
|
||||
|
||||
class TestUnusedSlotSubstitutionOnSinglePlateSource:
|
||||
"""#2711: a single-plate 3MF must still get its unused slots substituted.
|
||||
|
||||
The SliceModal omits ``plate`` entirely for single-plate and STL sources —
|
||||
it skips the plate picker, so ``selectedPlate`` stays null and the field
|
||||
never reaches the body. The schema documents an absent plate as "plate 1",
|
||||
but the substitution used to read it as "unknown plate" and skip, so every
|
||||
single-plate project reached the CLI with the dropdown values of slots the
|
||||
plate never paints with.
|
||||
|
||||
In the reported case that was a MakerWorld project declaring four filaments
|
||||
while plate 1 paints with one, the other three carrying presets baked into
|
||||
the source for a different printer. The CLI rejected the whole slice with
|
||||
"filament preset ... (slot 1) is not compatible with printer ...", and the
|
||||
modal disables unused rows so there was no way to correct it by hand.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _single_plate_using_only_slot_3() -> bytes:
|
||||
"""One plate, one object, painted with slot 3 — slots 1, 2 and 4 are
|
||||
declared by the project but unused. Mirrors the reported file."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
zf.writestr(
|
||||
"Metadata/project_settings.config",
|
||||
json.dumps({"filament_type": ["PLA", "PLA", "PLA", "TPU"]}),
|
||||
)
|
||||
zf.writestr(
|
||||
"Metadata/model_settings.config",
|
||||
"<?xml version='1.0'?>\n<config>"
|
||||
'<object id="1"><metadata key="extruder" value="3"/></object>'
|
||||
'<plate><metadata key="plater_id" value="1"/>'
|
||||
'<model_instance><metadata key="object_id" value="1"/>'
|
||||
'<metadata key="instance_id" value="0"/></model_instance>'
|
||||
"</plate></config>",
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
@staticmethod
|
||||
def _filament_names_sent(body: bytes) -> list[str]:
|
||||
"""Pull the ``name`` of each ``filamentProfile`` part, in slot order.
|
||||
|
||||
``slice_model`` sends one repeated ``filamentProfile`` part per slot as
|
||||
``filament_N.json``; the parts stay in submission order, so a plain
|
||||
scan preserves the slot mapping.
|
||||
"""
|
||||
names: list[str] = []
|
||||
marker = b'name="filamentProfile"; filename="filament_'
|
||||
pos = body.find(marker)
|
||||
while pos != -1:
|
||||
start = body.find(b"{", pos)
|
||||
end = body.find(b"\r\n", start)
|
||||
names.append(json.loads(body[start:end].decode("utf-8"))["name"])
|
||||
pos = body.find(marker, end)
|
||||
return names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unused_slots_are_substituted_when_the_body_omits_plate(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
tmp_path = slice_test_setup["tmp_path"]
|
||||
src = tmp_path / "library" / "files" / "train.3mf"
|
||||
src.write_bytes(self._single_plate_using_only_slot_3())
|
||||
threemf = LibraryFile(
|
||||
filename="train.3mf",
|
||||
file_path=str(src.relative_to(tmp_path)),
|
||||
file_type="3mf",
|
||||
file_size=src.stat().st_size,
|
||||
)
|
||||
db_session.add(threemf)
|
||||
|
||||
# Four distinguishable filament presets, one per project slot. Only
|
||||
# slot 3's is compatible with the target in the reported scenario.
|
||||
slots = []
|
||||
for i in range(1, 5):
|
||||
p = LocalPreset(
|
||||
name=f"slot{i}",
|
||||
preset_type="filament",
|
||||
source="orcaslicer",
|
||||
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
|
||||
)
|
||||
db_session.add(p)
|
||||
slots.append(p)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(threemf)
|
||||
for p in slots:
|
||||
await db_session.refresh(p)
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
captured.append(self._filament_names_sent(request.content))
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=_make_3mf_with_settings(),
|
||||
headers={
|
||||
"x-print-time-seconds": "100",
|
||||
"x-filament-used-g": "1.0",
|
||||
"x-filament-used-mm": "100",
|
||||
},
|
||||
)
|
||||
|
||||
_install_mock_sidecar(handler)
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{threemf.id}/slice",
|
||||
json={
|
||||
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
|
||||
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
|
||||
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
|
||||
# No "plate" — exactly what the modal sends for a single-plate
|
||||
# source. This is the whole point of the test.
|
||||
},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
|
||||
final = await _wait_for_job(async_client, response.json()["job_id"])
|
||||
assert final["status"] == "completed", final
|
||||
|
||||
assert captured, "sidecar was never called"
|
||||
# Every slot carries slot 3's profile: the array length stays intact
|
||||
# (the source's per-slot references depend on it) while nothing the
|
||||
# plate doesn't print with can fail the CLI's validators.
|
||||
assert captured[0] == ["slot3", "slot3", "slot3", "slot3"], captured[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slice_all_keeps_every_slot(self, async_client: AsyncClient, db_session, slice_test_setup):
|
||||
"""``plate=0`` is the all-plates sentinel, so nothing is unused.
|
||||
|
||||
It reaches the same call site, and plate ids are 1-indexed — the
|
||||
geometry lookup for plate 0 matches nothing. Without an explicit
|
||||
exclusion the project's support-filament slot would be the only
|
||||
member of the used set and would be copied over every colour.
|
||||
"""
|
||||
tmp_path = slice_test_setup["tmp_path"]
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
zf.writestr(
|
||||
"Metadata/project_settings.config",
|
||||
json.dumps(
|
||||
{
|
||||
"enable_support": "1",
|
||||
"support_filament": "4",
|
||||
"support_interface_filament": "4",
|
||||
"filament_type": ["PLA", "PLA", "PLA", "PVA"],
|
||||
}
|
||||
),
|
||||
)
|
||||
zf.writestr(
|
||||
"Metadata/model_settings.config",
|
||||
"<?xml version='1.0'?>\n<config>"
|
||||
'<object id="1"><metadata key="extruder" value="1"/></object>'
|
||||
'<object id="2"><metadata key="extruder" value="2"/></object>'
|
||||
'<plate><metadata key="plater_id" value="1"/>'
|
||||
'<model_instance><metadata key="object_id" value="1"/></model_instance></plate>'
|
||||
'<plate><metadata key="plater_id" value="2"/>'
|
||||
'<model_instance><metadata key="object_id" value="2"/></model_instance></plate>'
|
||||
"</config>",
|
||||
)
|
||||
src = tmp_path / "library" / "files" / "multi.3mf"
|
||||
src.write_bytes(buf.getvalue())
|
||||
threemf = LibraryFile(
|
||||
filename="multi.3mf",
|
||||
file_path=str(src.relative_to(tmp_path)),
|
||||
file_type="3mf",
|
||||
file_size=src.stat().st_size,
|
||||
)
|
||||
db_session.add(threemf)
|
||||
|
||||
slots = []
|
||||
for i in range(1, 5):
|
||||
p = LocalPreset(
|
||||
name=f"slot{i}",
|
||||
preset_type="filament",
|
||||
source="orcaslicer",
|
||||
setting=json.dumps({"name": f"slot{i}", "type": "filament"}),
|
||||
)
|
||||
db_session.add(p)
|
||||
slots.append(p)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(threemf)
|
||||
for p in slots:
|
||||
await db_session.refresh(p)
|
||||
|
||||
captured: list[list[str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if not _is_slice_post(request):
|
||||
return httpx.Response(404)
|
||||
captured.append(self._filament_names_sent(request.content))
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=_make_3mf_with_settings(),
|
||||
headers={
|
||||
"x-print-time-seconds": "100",
|
||||
"x-filament-used-g": "1.0",
|
||||
"x-filament-used-mm": "100",
|
||||
},
|
||||
)
|
||||
|
||||
_install_mock_sidecar(handler)
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{threemf.id}/slice",
|
||||
json={
|
||||
"printer_preset": {"source": "local", "id": str(slice_test_setup["printer_id"])},
|
||||
"process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
|
||||
"filament_presets": [{"source": "local", "id": str(p.id)} for p in slots],
|
||||
"plate": 0,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 202, response.text
|
||||
|
||||
final = await _wait_for_job(async_client, response.json()["job_id"])
|
||||
assert final["status"] == "completed", final
|
||||
|
||||
assert captured, "sidecar was never called"
|
||||
assert captured[0] == ["slot1", "slot2", "slot3", "slot4"], captured[0]
|
||||
|
||||
|
||||
class TestFilamentRequirementsFullSlots:
|
||||
"""#2712: what the slice modal is handed must be positional.
|
||||
|
||||
The modal's filament list maps index 0 to slot 1, and the backend forwards
|
||||
it in that order as ``filament_1.json``..``filament_N.json``. A MakerWorld
|
||||
source that ships slice_info and paints with slot 4 alone therefore has to
|
||||
present four rows — a one-row list binds the user's pick to slot 1, and
|
||||
slot 4 slices with whatever the source had baked in. Picking PETG produced
|
||||
a PLA print, and the print dialog then correctly refused to match PETG.
|
||||
|
||||
Print-time AMS matching shares this endpoint and needs the opposite: only
|
||||
the slots the plate consumes, so it doesn't demand spools for slots the
|
||||
G-code never touches. Hence the opt-in flag rather than a shape change.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _sliced_source_using_only_slot_4() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
zf.writestr(
|
||||
"Metadata/project_settings.config",
|
||||
json.dumps(
|
||||
{
|
||||
"filament_type": ["PLA", "PLA", "PLA", "PLA"],
|
||||
"filament_colour": ["#38CC0A", "#161616", "#898989", "#898989"],
|
||||
}
|
||||
),
|
||||
)
|
||||
# MakerWorld ships slice_info without plate G-code, which is what
|
||||
# sends this file down the "already sliced" branch.
|
||||
zf.writestr(
|
||||
"Metadata/slice_info.config",
|
||||
"<?xml version='1.0'?>\n<config><plate>"
|
||||
"<metadata key='index' value='1'/>"
|
||||
"<filament id='4' tray_info_idx='GFL99' type='PLA' color='#898989'"
|
||||
" used_m='35.51' used_g='105.92'/>"
|
||||
"</plate></config>",
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
async def _make_file(self, db_session, tmp_path) -> int:
|
||||
src = tmp_path / "library" / "files" / "tunnel.3mf"
|
||||
src.write_bytes(self._sliced_source_using_only_slot_4())
|
||||
lib = LibraryFile(
|
||||
filename="tunnel.3mf",
|
||||
file_path=str(src.relative_to(tmp_path)),
|
||||
file_type="3mf",
|
||||
file_size=src.stat().st_size,
|
||||
)
|
||||
db_session.add(lib)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib)
|
||||
return lib.id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_full_slots_returns_one_row_per_project_slot(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
|
||||
|
||||
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1&full_slots=true")
|
||||
assert r.status_code == 200, r.text
|
||||
filaments = r.json()["filaments"]
|
||||
|
||||
assert [f["slot_id"] for f in filaments] == [1, 2, 3, 4]
|
||||
# Only slot 4 prints, so only its row is selectable in the modal.
|
||||
assert [f["used_in_plate"] for f in filaments] == [False, False, False, True]
|
||||
# The used row keeps what the slice actually reported.
|
||||
assert filaments[3]["used_grams"] == 105.9
|
||||
assert filaments[3]["tray_info_idx"] == "GFL99"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_print_path_still_gets_only_the_used_slot(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
"""Without the flag the response must be byte-for-byte what it was.
|
||||
|
||||
PrintModal drives AMS matching off this; widening it would ask the
|
||||
user to load three spools the print never touches.
|
||||
"""
|
||||
file_id = await self._make_file(db_session, slice_test_setup["tmp_path"])
|
||||
|
||||
r = await async_client.get(f"/api/v1/library/files/{file_id}/filament-requirements?plate_id=1")
|
||||
assert r.status_code == 200, r.text
|
||||
filaments = r.json()["filaments"]
|
||||
|
||||
assert [f["slot_id"] for f in filaments] == [4]
|
||||
assert filaments[0]["used_in_plate"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unsliced_sources_are_unaffected_by_the_flag(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
"""Those already returned the full project list; the flag must not
|
||||
double-handle them or change what the modal has been getting."""
|
||||
tmp_path = slice_test_setup["tmp_path"]
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("3D/3dmodel.model", "<model/>")
|
||||
zf.writestr(
|
||||
"Metadata/project_settings.config",
|
||||
json.dumps({"filament_type": ["PLA", "PETG"], "filament_colour": ["#000000", "#FFFFFF"]}),
|
||||
)
|
||||
src = tmp_path / "library" / "files" / "raw.3mf"
|
||||
src.write_bytes(buf.getvalue())
|
||||
lib = LibraryFile(
|
||||
filename="raw.3mf",
|
||||
file_path=str(src.relative_to(tmp_path)),
|
||||
file_type="3mf",
|
||||
file_size=src.stat().st_size,
|
||||
)
|
||||
db_session.add(lib)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib)
|
||||
|
||||
with_flag = await async_client.get(
|
||||
f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1&full_slots=true"
|
||||
)
|
||||
without = await async_client.get(f"/api/v1/library/files/{lib.id}/filament-requirements?plate_id=1")
|
||||
|
||||
assert with_flag.status_code == 200 and without.status_code == 200
|
||||
assert with_flag.json()["filaments"] == without.json()["filaments"]
|
||||
assert [f["slot_id"] for f in with_flag.json()["filaments"]] == [1, 2]
|
||||
|
|
|
|||
|
|
@ -96,6 +96,35 @@ class TestLocalLoginGate:
|
|||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unrecognized_env_value_does_not_500_the_login_path(
|
||||
self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
|
||||
an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
|
||||
never raise -- env_bool is strict for the startup OIDC reader but lenient
|
||||
here. A raise would 500 the very endpoint the bypass exists to keep open."""
|
||||
await _enable_auth(async_client, "gateonval")
|
||||
await _set_setting(db_session, "local_login_enabled", "false")
|
||||
monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "gateonval", "password": "GatePass1!"},
|
||||
)
|
||||
# Bypass stays off (same 401 as no env var), and crucially not a 500.
|
||||
assert response.status_code == 401, response.text
|
||||
|
||||
def test_the_bypass_var_is_registered_in_the_typo_guard(self):
|
||||
"""config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
|
||||
Unregistered, this one tells an operator who is locked out and following
|
||||
the documented recovery that the variable they just set is not real --
|
||||
while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
|
||||
from backend.app.core.config import _INTENTIONAL_UNSETTINGS
|
||||
|
||||
assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_forgot_password_rejected_when_local_disabled(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ hardcoded 5s read timeout by pre-populating a cache before issuing the ML call.
|
|||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.obico_detection import _frame_cache, stash_frame
|
||||
from backend.app.services.obico_detection import _frame_cache, obico_detection_service, stash_frame
|
||||
from backend.app.services.obico_smoothing import PrintState
|
||||
|
||||
FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
||||
|
||||
|
|
@ -69,3 +70,75 @@ class TestObicoCachedFrame:
|
|||
response = await async_client.get(f"/api/v1/obico/cached-frame/{nonce}")
|
||||
assert response.status_code == 200
|
||||
assert "no-store" in response.headers.get("cache-control", "")
|
||||
|
||||
|
||||
class TestObicoPrinterStatus:
|
||||
"""The lightweight /obico/printer-status endpoint for printer-card badges (#1546)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_detection_state(self):
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._last_error = None
|
||||
yield
|
||||
obico_detection_service._states.clear()
|
||||
obico_detection_service._last_class.clear()
|
||||
obico_detection_service._last_error = None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_returns_per_printer_classification(self, async_client: AsyncClient):
|
||||
state = PrintState()
|
||||
state.update(0.5)
|
||||
obico_detection_service._states[1] = state
|
||||
obico_detection_service._last_class[1] = "warning"
|
||||
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "enabled" in data
|
||||
# None = all printers monitored (no obico_enabled_printers subset configured)
|
||||
assert data["monitored_printers"] is None
|
||||
entry = data["per_printer"]["1"]
|
||||
assert entry["class"] == "warning"
|
||||
assert entry["frame_count"] == 1
|
||||
assert isinstance(entry["score"], float)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_when_nothing_monitored(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["per_printer"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_monitored_subset_is_returned(self, async_client: AsyncClient):
|
||||
"""A configured obico_enabled_printers subset surfaces (as a sorted list) so
|
||||
the frontend can show the idle badge only on monitored printers."""
|
||||
update = await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": "[3, 1]"})
|
||||
assert update.status_code == 200
|
||||
try:
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
assert response.json()["monitored_printers"] == [1, 3]
|
||||
finally:
|
||||
await async_client.put("/api/v1/settings/", json={"obico_enabled_printers": ""})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_last_error_is_surfaced(self, async_client: AsyncClient):
|
||||
"""The badge modal shows the service's last error (auth disabled in the
|
||||
test env, so the settings:read gate on the field is open)."""
|
||||
obico_detection_service._last_error = "Failed to capture snapshot for printer 1"
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
assert response.json()["last_error"] == "Failed to capture snapshot for printer 1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_does_not_leak_settings(self, async_client: AsyncClient):
|
||||
"""Unlike /obico/status, this endpoint is readable with printers:read only,
|
||||
so it must not expose the ML URL or other configuration."""
|
||||
response = await async_client.get("/api/v1/obico/printer-status")
|
||||
data = response.json()
|
||||
for key in ("ml_url", "action", "history", "poll_interval", "external_url_configured"):
|
||||
assert key not in data
|
||||
|
|
|
|||
801
backend/tests/integration/test_oidc_env_apply.py
Normal file
801
backend/tests/integration/test_oidc_env_apply.py
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
"""Upserting the env-managed OIDC provider (#2593).
|
||||
|
||||
Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
|
||||
never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
|
||||
recreating the provider would silently unlink every account bound to it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.oidc_env import apply_env_oidc_provider
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
|
||||
REQUIRED = {
|
||||
"BAMBUDDY_OIDC_NAME": "Keycloak",
|
||||
"BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
|
||||
"BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
|
||||
"BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
|
||||
}
|
||||
|
||||
ALL_VARS = (
|
||||
*REQUIRED,
|
||||
"BAMBUDDY_OIDC_SCOPES",
|
||||
"BAMBUDDY_OIDC_ENABLED",
|
||||
"BAMBUDDY_OIDC_AUTO_CREATE_USERS",
|
||||
"BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
|
||||
"BAMBUDDY_OIDC_EMAIL_CLAIM",
|
||||
"BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
|
||||
"BAMBUDDY_OIDC_ICON_URL",
|
||||
"BAMBUDDY_OIDC_AUTOLOGIN",
|
||||
"BAMBUDDY_OIDC_DEFAULT_GROUP",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_env(monkeypatch):
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def _configure(monkeypatch, **overrides):
|
||||
for key, value in REQUIRED.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
for key, value in overrides.items():
|
||||
monkeypatch.setenv(key, value)
|
||||
|
||||
|
||||
async def _env_provider(db_session) -> OIDCProvider | None:
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_the_provider_from_env(db_session, monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None
|
||||
assert provider.name == "Keycloak"
|
||||
assert provider.client_id == "bambuddy"
|
||||
assert provider.is_env_managed is True
|
||||
assert provider.client_secret == "s3cr3t" # property decrypts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
|
||||
"""The id must survive: user_oidc_links references it with ON DELETE
|
||||
CASCADE, so a delete-recreate would unlink every bound account."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original_id = (await _env_provider(db_session)).id
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider.id == original_id
|
||||
assert provider.client_id == "rotated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original_id = (await _env_provider(db_session)).id
|
||||
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# Looked up by name, not by the flag: releasing the provider clears the flag,
|
||||
# and the point of this test is that the ROW survives either way.
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
|
||||
provider = result.scalar_one_or_none()
|
||||
assert provider is not None, "deleting would cascade away every account link"
|
||||
assert provider.id == original_id
|
||||
assert provider.is_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
|
||||
"""Only one provider may be the autologin target; the env one wins."""
|
||||
ui_provider = OIDCProvider(
|
||||
name="UI provider",
|
||||
issuer_url="https://other.example.com",
|
||||
client_id="ui",
|
||||
is_autologin=True,
|
||||
)
|
||||
ui_provider.client_secret = "ui-secret"
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
await db_session.refresh(ui_provider)
|
||||
assert (await _env_provider(db_session)).is_autologin is True
|
||||
assert ui_provider.is_autologin is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
|
||||
ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
|
||||
ui_provider.client_secret = "ui-secret"
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
await db_session.refresh(ui_provider)
|
||||
assert ui_provider.is_env_managed is False
|
||||
assert ui_provider.is_enabled is True
|
||||
assert ui_provider.client_id == "ui"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
|
||||
"""auto-link + unverified email is the SEC-1 account-takeover shape. The
|
||||
schema rejects it for the UI, and env config must not be a way around that
|
||||
-- but a bad variable must not stop the app from booting either."""
|
||||
_configure(
|
||||
monkeypatch,
|
||||
BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
|
||||
BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
|
||||
)
|
||||
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert await _env_provider(db_session) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
|
||||
"""client_secret has max_length=512, so an over-long value raises
|
||||
string_too_long. The rejection must be logged without the value: str(exc)
|
||||
embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
|
||||
secret = "S3CR3T" * 100 # > 512 chars -> ValidationError on client_secret
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert await _env_provider(db_session) is None # rejected, not booted-through
|
||||
assert "rejected" in caplog.text # the rejection was actually logged
|
||||
assert secret not in caplog.text
|
||||
assert "S3CR3T" not in caplog.text # not even a fragment of the value
|
||||
|
||||
|
||||
# --- an unrecognized boolean is rejected, not guessed --------------------------
|
||||
# `_env_bool` used to return the default for anything outside {true,1,yes}, so
|
||||
# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
|
||||
# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
|
||||
# refuses the config instead -- through the same clean path a bad
|
||||
# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
|
||||
# provider that was running fine.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original = await _env_provider(db_session)
|
||||
original_id, original_enabled = original.id, original.is_enabled
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None, "a typo must not release the provider"
|
||||
assert provider.id == original_id
|
||||
assert provider.is_enabled == original_enabled
|
||||
assert provider.is_env_managed is True
|
||||
assert "rejected" in caplog.text
|
||||
assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original = await _env_provider(db_session)
|
||||
original_id, original_enabled = original.id, original.is_enabled
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None, "a typo must not release the provider"
|
||||
assert provider.id == original_id
|
||||
assert provider.is_enabled == original_enabled
|
||||
assert provider.is_env_managed is True
|
||||
assert "rejected" in caplog.text
|
||||
assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
|
||||
"""The generic except branch handles anything that isn't a ValidationError
|
||||
(e.g. a library call raising mid-construction). It must not stop boot and,
|
||||
since such a message could carry a configured value, must log only the
|
||||
exception class -- never str(exc)."""
|
||||
# oidc_env imports OIDCProviderCreate inside the function (to avoid an
|
||||
# import cycle), so patch it at its source module, not on oidc_env.
|
||||
import backend.app.schemas.auth as auth_schemas
|
||||
|
||||
def _raise(**_kwargs):
|
||||
raise RuntimeError("boom leaked-secret")
|
||||
|
||||
monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session) # must not raise
|
||||
|
||||
assert await _env_provider(db_session) is None
|
||||
assert "could not be applied" in caplog.text
|
||||
assert "RuntimeError" in caplog.text # class is logged...
|
||||
assert "leaked-secret" not in caplog.text # ...but nothing from the message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
|
||||
"""The upsert's db.execute/db.commit calls sit outside the inner
|
||||
ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
|
||||
must not propagate out of the lifespan either. Only the exception class
|
||||
may be logged, never str(exc), since a DB error message can echo a
|
||||
configured value."""
|
||||
|
||||
async def _raise_on_commit():
|
||||
raise RuntimeError("database is locked")
|
||||
|
||||
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session) # must not raise
|
||||
|
||||
assert "could not be applied" in caplog.text
|
||||
assert "RuntimeError" in caplog.text # class is logged...
|
||||
assert "leaked-secret" not in caplog.text # ...but nothing from the message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
|
||||
"""The handler rolls back after a failed commit -- but rollback on a wedged
|
||||
connection can raise too, and 'never raises' has to hold for that as well
|
||||
or the boot dies on the recovery path. The rollback is suppressed."""
|
||||
|
||||
async def _raise_on_commit():
|
||||
raise RuntimeError("database is locked")
|
||||
|
||||
async def _raise_on_rollback():
|
||||
raise RuntimeError("connection is closed")
|
||||
|
||||
monkeypatch.setattr(db_session, "commit", _raise_on_commit)
|
||||
monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session) # must not raise, even here
|
||||
|
||||
assert "could not be applied" in caplog.text
|
||||
assert "leaked-secret" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
|
||||
"""Every boot re-applies; the second run must not create a second row."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
|
||||
assert len(result.scalars().all()) == 1
|
||||
|
||||
|
||||
# --- identity is the name, not the flag ---------------------------------------
|
||||
# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
|
||||
# Matching on is_env_managed instead made three things impossible: adopting a
|
||||
# provider that already carries the name (the insert hit the unique constraint
|
||||
# and took startup down with it), releasing the provider when the config goes
|
||||
# away, and finding it again afterwards.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
|
||||
"""An operator who names the env provider after one they created in the UI
|
||||
must not end up with an app that refuses to boot."""
|
||||
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
|
||||
ui_provider.client_secret = "ui-secret"
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
original_id = ui_provider.id
|
||||
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None
|
||||
assert provider.id == original_id, "adopted, not duplicated"
|
||||
assert provider.client_id == "bambuddy"
|
||||
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
|
||||
assert len(result.scalars().all()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
|
||||
"""Overwriting a UI-created provider in place is a bigger deal than a
|
||||
routine re-apply -- it must not be silent at the same INFO level."""
|
||||
ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
|
||||
ui_provider.client_secret = "ui-secret"
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
|
||||
_configure(monkeypatch)
|
||||
with caplog.at_level(logging.INFO):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert any("adopted" in r.message for r in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
|
||||
"""The same provider re-applying on the next boot is not an adoption --
|
||||
it was already env-managed."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
caplog.clear()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
|
||||
assert not any("adopted" in r.message for r in warnings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
|
||||
"""Nothing manages it any more, so the API must stop refusing edits and
|
||||
deletes -- otherwise the row is a dead end only reachable via the database."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
|
||||
provider = result.scalar_one()
|
||||
assert provider.is_enabled is False
|
||||
assert provider.is_env_managed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
|
||||
"""The account links hang off this row; a second provider would orphan them."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original_id = (await _env_provider(db_session)).id
|
||||
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider.id == original_id
|
||||
assert provider.is_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original_id = (await _env_provider(db_session)).id
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider.id == original_id
|
||||
assert provider.issuer_url == "https://sso.example.com/realms/other"
|
||||
assert provider.client_id == "rotated"
|
||||
|
||||
|
||||
# --- a rename must not leave the old row managed -------------------------------
|
||||
# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
|
||||
# creates a second row. Leaving the flag on the first one is what makes that
|
||||
# fatal: it stays enabled with a stale issuer and secret on the login page, the
|
||||
# API refuses every edit/disable/delete on it (409), and the release path's
|
||||
# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
|
||||
# the app stops booting. Both states are reachable by ordinary config edits.
|
||||
|
||||
|
||||
async def _env_managed(db_session) -> list[OIDCProvider]:
|
||||
result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
old_id = (await _env_provider(db_session)).id
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
managed = await _env_managed(db_session)
|
||||
assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
|
||||
|
||||
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
|
||||
# Released, not deleted -- user_oidc_links.provider_id cascades.
|
||||
assert old.is_env_managed is False
|
||||
assert old.is_enabled is False, "a stale issuer must not stay on the login page"
|
||||
assert old.is_autologin is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
|
||||
"""The MultipleResultsFound path: rename, then unset. Must not raise."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session) # must not raise
|
||||
|
||||
assert await _env_managed(db_session) == []
|
||||
names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
|
||||
assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
|
||||
"""The upsert's sweep should keep this at one row. Should is not enforced by
|
||||
the schema, and the cost of being wrong is the whole release path raising
|
||||
MultipleResultsFound out of the lifespan -- so it releases what it finds."""
|
||||
for name in ("Keycloak", "Authentik"):
|
||||
stale = OIDCProvider(
|
||||
name=name,
|
||||
issuer_url="https://sso.example.com/realms/main",
|
||||
client_id="bambuddy",
|
||||
is_env_managed=True,
|
||||
)
|
||||
stale.client_secret = "s3cr3t"
|
||||
db_session.add(stale)
|
||||
await db_session.commit()
|
||||
|
||||
await apply_env_oidc_provider(db_session) # no vars set -> release path
|
||||
|
||||
assert await _env_managed(db_session) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
|
||||
"""is_enabled and is_env_managed alone leave a UI-editable row carrying a
|
||||
latent autologin claim: update_oidc_provider only runs the exclusivity
|
||||
sweep when a request sets is_autologin=True, so merely re-enabling this row
|
||||
makes it the autologin target again."""
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
assert (await _env_provider(db_session)).is_autologin is True
|
||||
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
|
||||
assert released.is_autologin is False
|
||||
|
||||
|
||||
# --- default group by name -----------------------------------------------------
|
||||
# Group ids are not stable across installs, so a declarative deployment cannot
|
||||
# name one by id. Without this, every auto-created user falls back to Viewers
|
||||
# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
|
||||
|
||||
|
||||
async def _group(db_session, name: str):
|
||||
from backend.app.models.group import Group
|
||||
|
||||
group = Group(name=name, description=f"Test group {name}")
|
||||
db_session.add(group)
|
||||
await db_session.commit()
|
||||
return group
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
|
||||
group = await _group(db_session, "Operators")
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
|
||||
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert (await _env_provider(db_session)).default_group_id == group.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
|
||||
"""Silently falling back to Viewers is how a typo mints under-privileged
|
||||
users for weeks. The API answers 400 for a default_group_id that does not
|
||||
exist; env config gets the same answer, logged and survivable."""
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert await _env_provider(db_session) is None
|
||||
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
|
||||
assert "Nope" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
|
||||
"""Rejection happens before the upsert, so the running config survives a
|
||||
bad edit -- the provider keeps working until the operator fixes the name."""
|
||||
group = await _group(db_session, "Operators")
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None
|
||||
assert provider.default_group_id == group.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert (await _env_provider(db_session)).default_group_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
|
||||
"""The environment is the whole truth for this row; a group that is no
|
||||
longer declared must not linger, since the lock blocks removing it in the UI."""
|
||||
await _group(db_session, "Operators")
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
assert (await _env_provider(db_session)).default_group_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
|
||||
"""Same rule the required vars follow: an empty value in a compose file is
|
||||
a forgotten value, not a request to reject the config."""
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None
|
||||
assert provider.default_group_id is None
|
||||
|
||||
|
||||
# --- blank optional strings count as unset, not a refusal ---------------------
|
||||
# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
|
||||
# it must not take the provider down -- same rule default_group already follows.
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None, "a blank optional var must not refuse the whole provider"
|
||||
assert provider.scopes == "openid email profile"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None, "a blank optional var must not refuse the whole provider"
|
||||
assert provider.email_claim == "email"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider is not None, "a blank optional var must not refuse the whole provider"
|
||||
assert provider.icon_url is None
|
||||
|
||||
|
||||
# --- account links and collision behavior ------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
|
||||
"""New name collides with existing UI provider: env config adopts that row,
|
||||
old env-managed row is released. Identity is the name, so the collision is
|
||||
resolved by matching the new name against the table."""
|
||||
# Start with env-managed "Keycloak"
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
old_id = (await _env_provider(db_session)).id
|
||||
|
||||
# Add a UI provider named "Authentik"
|
||||
ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
|
||||
ui_provider.client_secret = "ui-secret"
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
ui_id = ui_provider.id
|
||||
|
||||
# Rename env provider to "Authentik" — matches the UI provider
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# The UI provider is adopted and becomes env-managed
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider.id == ui_id, "adopted the UI provider"
|
||||
assert provider.name == "Authentik"
|
||||
assert provider.client_id == "bambuddy" # updated from env
|
||||
assert provider.is_env_managed is True
|
||||
|
||||
# The old Keycloak row is released
|
||||
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
|
||||
assert old.name == "Keycloak"
|
||||
assert old.is_env_managed is False
|
||||
assert old.is_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
|
||||
"""The provider row is never deleted, only updated: user_oidc_links FK
|
||||
ON DELETE CASCADE must not be triggered by a rename."""
|
||||
from backend.app.models.oidc_provider import UserOIDCLink
|
||||
from backend.app.models.user import User
|
||||
|
||||
# Create a user and link it to the env-managed provider
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
provider_id = (await _env_provider(db_session)).id
|
||||
|
||||
user = User(username="testuser", email="test@example.com")
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
|
||||
link = UserOIDCLink(
|
||||
user_id=user.id,
|
||||
provider_id=provider_id,
|
||||
provider_user_id="oidc-sub-12345",
|
||||
provider_email="test@idp.example.com",
|
||||
)
|
||||
db_session.add(link)
|
||||
await db_session.commit()
|
||||
|
||||
# Rename the env provider
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# The link still exists, pointing to the old row (which is now released)
|
||||
result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
|
||||
links = result.scalars().all()
|
||||
assert len(links) == 1
|
||||
assert links[0].provider_user_id == "oidc-sub-12345"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
|
||||
"""When renamed env config has autologin=true, the sweep clears autologin
|
||||
from other rows. The old row is released (autologin cleared there too)."""
|
||||
# Setup: env provider "Keycloak" with autologin
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
old_id = (await _env_provider(db_session)).id
|
||||
assert (await _env_provider(db_session)).is_autologin is True
|
||||
|
||||
# Another UI provider also has autologin
|
||||
ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
|
||||
ui_provider.client_secret = "secret"
|
||||
ui_provider.is_autologin = True
|
||||
db_session.add(ui_provider)
|
||||
await db_session.commit()
|
||||
|
||||
# Rename env provider to "Authentik" with autologin=true
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# New row is the autologin target
|
||||
new_provider = await _env_provider(db_session)
|
||||
assert new_provider.name == "Authentik"
|
||||
assert new_provider.is_autologin is True
|
||||
|
||||
# Old row is released and autologin cleared
|
||||
old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
|
||||
assert old.is_env_managed is False
|
||||
assert old.is_autologin is False
|
||||
|
||||
# UI provider autologin is cleared (only env-managed can be autologin now)
|
||||
await db_session.refresh(ui_provider)
|
||||
assert ui_provider.is_autologin is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
|
||||
"""Group name is resolved by exact match; 'operators' != 'Operators'."""
|
||||
await _group(db_session, "Operators") # capital O
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators") # lowercase
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# Config is rejected
|
||||
assert await _env_provider(db_session) is None
|
||||
assert "operators" in caplog.text
|
||||
assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
|
||||
"""Group resolution happens before schema validation, so the secret is
|
||||
not yet in scope, but verify it's not leaked by the error path."""
|
||||
_configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
|
||||
secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# Config is rejected but secret is safe
|
||||
assert await _env_provider(db_session) is None
|
||||
assert secret not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
|
||||
"""Rename Keycloak → Authentik, unset everything, restore Keycloak.
|
||||
Must re-enable the original row, not create a new one."""
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
original_id = (await _env_provider(db_session)).id
|
||||
|
||||
# Rename to Authentik
|
||||
monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
|
||||
await apply_env_oidc_provider(db_session)
|
||||
assert (await _env_provider(db_session)).name == "Authentik"
|
||||
|
||||
# Unset everything
|
||||
for key in ALL_VARS:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# Restore the original Keycloak config
|
||||
_configure(monkeypatch)
|
||||
await apply_env_oidc_provider(db_session)
|
||||
|
||||
# Same row, re-enabled
|
||||
provider = await _env_provider(db_session)
|
||||
assert provider.id == original_id
|
||||
assert provider.name == "Keycloak"
|
||||
assert provider.is_enabled is True
|
||||
assert provider.is_env_managed is True
|
||||
137
backend/tests/integration/test_oidc_env_lock.py
Normal file
137
backend/tests/integration/test_oidc_env_lock.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""The env-managed provider is read-only through the API (#2593).
|
||||
|
||||
Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
|
||||
would silently disappear at the next restart -- the operator would see their
|
||||
change accepted and then reverted, with nothing explaining why. Refusing the
|
||||
write is the honest answer.
|
||||
|
||||
Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
|
||||
recovery path if the provider itself becomes unusable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.models.oidc_provider import OIDCProvider
|
||||
from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
|
||||
|
||||
|
||||
async def _env_managed_provider(db_session) -> int:
|
||||
provider = OIDCProvider(
|
||||
name="Env Keycloak",
|
||||
issuer_url="https://sso.example.com/realms/main",
|
||||
client_id="bambuddy",
|
||||
icon_url="https://sso.example.com/logo.png",
|
||||
is_env_managed=True,
|
||||
)
|
||||
provider.client_secret = "s3cr3t"
|
||||
db_session.add(provider)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(provider)
|
||||
return provider.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_put_is_refused(async_client: AsyncClient, db_session):
|
||||
provider_id = await _env_managed_provider(db_session)
|
||||
token = await _setup_and_login(async_client, "envlockput", "envlockput123")
|
||||
|
||||
response = await async_client.put(
|
||||
f"/api/v1/auth/oidc/providers/{provider_id}",
|
||||
json={"name": "hijacked"},
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_is_refused(async_client: AsyncClient, db_session):
|
||||
provider_id = await _env_managed_provider(db_session)
|
||||
token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/auth/oidc/providers/{provider_id}",
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
|
||||
"""The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
|
||||
provider_id = await _env_managed_provider(db_session)
|
||||
token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/auth/oidc/providers/{provider_id}/icon",
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
|
||||
provider_id = await _env_managed_provider(db_session)
|
||||
token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
|
||||
"""The lock must not leak onto providers the operator created themselves --
|
||||
they coexist with the env one and stay fully editable."""
|
||||
token = await _setup_and_login(async_client, "envlockui", "envlockui123")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/oidc/providers",
|
||||
json={
|
||||
"name": "UI provider",
|
||||
"issuer_url": "https://other.example.com",
|
||||
"client_id": "ui",
|
||||
"client_secret": "ui-secret",
|
||||
"scopes": "openid",
|
||||
"is_enabled": True,
|
||||
"auto_create_users": False,
|
||||
},
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
provider_id = created.json()["id"]
|
||||
|
||||
response = await async_client.put(
|
||||
f"/api/v1/auth/oidc/providers/{provider_id}",
|
||||
json={"name": "Renamed"},
|
||||
headers=_auth_header(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Renamed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
|
||||
"""The frontend needs this to render the lock; without it the UI would show
|
||||
editable fields whose writes the API then refuses."""
|
||||
await _env_managed_provider(db_session)
|
||||
token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
|
||||
|
||||
response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
providers = response.json()
|
||||
assert any(p["is_env_managed"] for p in providers)
|
||||
43
backend/tests/integration/test_oidc_env_startup.py
Normal file
43
backend/tests/integration/test_oidc_env_startup.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""The env provider is applied on startup, not merely appliable (#2593).
|
||||
|
||||
test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
|
||||
green even if nothing ever calls it -- deleting the lifespan call would leave
|
||||
the feature dead with a fully passing suite. These tests pin the call site.
|
||||
|
||||
They read the lifespan's source rather than running it: the function is ~460
|
||||
lines and starts printer connections, MQTT and schedulers, so executing it
|
||||
here would test everything except the one line in question. That makes this a
|
||||
wiring check, not a behavioural one -- it proves the call exists and runs
|
||||
after migrations, and deliberately proves nothing about what it does. The
|
||||
behaviour is covered by test_oidc_env_apply.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from backend.app.main import lifespan
|
||||
|
||||
|
||||
def _lifespan_source() -> str:
|
||||
return inspect.getsource(lifespan)
|
||||
|
||||
|
||||
def test_lifespan_applies_the_env_oidc_provider():
|
||||
assert "apply_env_oidc_provider(" in _lifespan_source()
|
||||
|
||||
|
||||
def test_it_runs_after_the_migrations():
|
||||
"""is_env_managed does not exist until run_migrations has added it, so an
|
||||
upsert before init_db() would fail on every existing installation."""
|
||||
source = _lifespan_source()
|
||||
assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
|
||||
|
||||
|
||||
def test_the_apply_call_is_awaited():
|
||||
"""apply_env_oidc_provider is a coroutine; calling it without await would
|
||||
return an un-awaited coroutine and silently apply nothing."""
|
||||
source = _lifespan_source()
|
||||
call = source.index("apply_env_oidc_provider(")
|
||||
line_start = source.rindex("\n", 0, call) + 1
|
||||
assert source[line_start:call].strip().endswith("await")
|
||||
|
|
@ -827,13 +827,51 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|||
|
||||
assert response.status_code == 403
|
||||
|
||||
# ========================================================================
|
||||
# Folder deletion (#1781): folders have no ownership tracking, so users
|
||||
# with only library:delete_own may delete empty, non-external, non-linked
|
||||
# folders. Everything else still requires library:delete_all.
|
||||
# ========================================================================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_folders_require_all_permission(self, async_client: AsyncClient, auth_setup, library_folder_factory):
|
||||
"""Folders require *_all permission (no ownership tracking on folders)."""
|
||||
folder = await library_folder_factory(name="TestFolder")
|
||||
async def test_operator_can_delete_empty_folder(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
||||
):
|
||||
"""A user with library:delete_own can delete an empty folder (#1781)."""
|
||||
folder = await library_folder_factory(name="EmptyFolder")
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_viewer_cannot_delete_empty_folder(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
||||
):
|
||||
"""No delete permission at all still means no folder deletion."""
|
||||
folder = await library_folder_factory(name="EmptyFolder")
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['viewer_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_delete_folder_with_files(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
||||
):
|
||||
"""Non-empty folders still require library:delete_all."""
|
||||
folder = await library_folder_factory(name="FullFolder")
|
||||
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
|
||||
|
||||
# Operator cannot delete folder (needs *_all)
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
|
|
@ -841,6 +879,121 @@ class TestLibraryOwnershipPermissions(TestOwnershipPermissionsSetup):
|
|||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_delete_folder_with_trashed_file(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
||||
):
|
||||
"""Trashed files count as content: cascade would hard-drop them and
|
||||
silently break trash restore for their owner."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
folder = await library_folder_factory(name="TrashedContentFolder")
|
||||
await library_file_factory(
|
||||
folder_id=folder.id,
|
||||
created_by_id=auth_setup["operator2_user"]["id"],
|
||||
deleted_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_delete_folder_with_subfolder(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
||||
):
|
||||
"""A folder containing subfolders (even empty ones) is not empty."""
|
||||
parent = await library_folder_factory(name="ParentFolder")
|
||||
await library_folder_factory(name="ChildFolder", parent_id=parent.id)
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{parent.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_delete_external_folder(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory
|
||||
):
|
||||
"""Deleting an external folder unmounts an operator-configured mount
|
||||
for everyone — stays behind library:delete_all even when empty."""
|
||||
folder = await library_folder_factory(name="ExternalFolder", is_external=True, external_path="/mnt/models")
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_operator_cannot_delete_linked_folder(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory, db_session
|
||||
):
|
||||
"""Project/archive links are created via update_all, so unlinking by
|
||||
deletion stays admin-only even for empty folders."""
|
||||
from backend.app.models.project import Project
|
||||
|
||||
project = Project(name="LinkTestProject")
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
|
||||
folder = await library_folder_factory(name="LinkedFolder", project_id=project.id)
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_admin_can_delete_folder_with_contents(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
||||
):
|
||||
"""library:delete_all keeps full cascade deletion."""
|
||||
folder = await library_folder_factory(name="AdminFolder")
|
||||
await library_file_factory(folder_id=folder.id, created_by_id=auth_setup["operator_user"]["id"])
|
||||
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/library/folders/{folder.id}",
|
||||
headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_delete_operator_folders_empty_only(
|
||||
self, async_client: AsyncClient, auth_setup, library_folder_factory, library_file_factory
|
||||
):
|
||||
"""Bulk delete applies the same rule: empty folders go, non-empty are skipped."""
|
||||
empty_folder = await library_folder_factory(name="BulkEmpty")
|
||||
full_folder = await library_folder_factory(name="BulkFull")
|
||||
await library_file_factory(folder_id=full_folder.id, created_by_id=auth_setup["operator2_user"]["id"])
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/bulk-delete",
|
||||
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
|
||||
json={"file_ids": [], "folder_ids": [empty_folder.id, full_folder.id]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["deleted_folders"] == 1
|
||||
assert result["deleted_files"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_delete_skips_non_owned_files(self, async_client: AsyncClient, auth_setup, library_file_factory):
|
||||
|
|
|
|||
79
backend/tests/integration/test_plate_clear_notification.py
Normal file
79
backend/tests/integration/test_plate_clear_notification.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Integration tests for the plate-clear-required notification (#2525).
|
||||
|
||||
The event is opt-in: it fires after every print, at the same moment as the
|
||||
print-complete alert, so a provider only receives it when the toggle is
|
||||
explicitly enabled.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.notification_service import notification_service
|
||||
|
||||
|
||||
class TestPlateClearNotificationDispatch:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_sends_to_a_provider_that_opted_in(self, notification_provider_factory, db_session):
|
||||
await notification_provider_factory(name="Opted In", on_plate_clear_required=True)
|
||||
|
||||
send = AsyncMock()
|
||||
with patch.object(notification_service, "_send_to_providers", send):
|
||||
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
|
||||
|
||||
assert send.await_count == 1
|
||||
providers = send.await_args.args[0]
|
||||
assert [p.name for p in providers] == ["Opted In"]
|
||||
assert send.await_args.args[4] == "plate_clear_required"
|
||||
# _build_message_from_template folds in app_name/timestamp; the caller's
|
||||
# own variable is what matters here.
|
||||
assert send.await_args.kwargs["variables"]["printer"] == "Workshop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_silent_for_a_provider_that_did_not_opt_in(self, notification_provider_factory, db_session):
|
||||
await notification_provider_factory(name="Default Off", on_plate_clear_required=False)
|
||||
|
||||
send = AsyncMock()
|
||||
with patch.object(notification_service, "_send_to_providers", send):
|
||||
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
|
||||
|
||||
send.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_skips_a_provider_scoped_to_a_different_printer(self, notification_provider_factory, db_session):
|
||||
await notification_provider_factory(name="Other Printer", on_plate_clear_required=True, printer_id=99)
|
||||
|
||||
send = AsyncMock()
|
||||
with patch.object(notification_service, "_send_to_providers", send):
|
||||
await notification_service.on_plate_clear_required(1, "Workshop", db_session)
|
||||
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
class TestPlateClearProviderField:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_defaults_to_off_on_create_and_round_trips_on_update(self, async_client: AsyncClient):
|
||||
create = await async_client.post(
|
||||
"/api/v1/notifications/",
|
||||
json={
|
||||
"name": "Plate Clear Test",
|
||||
"provider_type": "ntfy",
|
||||
"enabled": True,
|
||||
"config": {"server": "https://ntfy.sh", "topic": "test-topic"},
|
||||
},
|
||||
)
|
||||
assert create.status_code in (200, 201), create.text
|
||||
provider_id = create.json()["id"]
|
||||
assert create.json()["on_plate_clear_required"] is False
|
||||
|
||||
update = await async_client.patch(
|
||||
f"/api/v1/notifications/{provider_id}",
|
||||
json={"on_plate_clear_required": True},
|
||||
)
|
||||
assert update.status_code == 200, update.text
|
||||
assert update.json()["on_plate_clear_required"] is True
|
||||
|
|
@ -258,6 +258,211 @@ class TestPrintQueueAPI:
|
|||
assert result["archive_id"] == archive.id
|
||||
assert result["ams_mapping"] == [5, -1, 2, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""When the caller sends no explicit ams_mapping, but the archive
|
||||
carries the slicer's own saved pick for this exact printer
|
||||
(extra_data.slicer_ams_mapping, written by a VP with "Save AMS
|
||||
mapping" on), the queue item should inherit it — the same
|
||||
exact-physical-spool reuse the "Mapping" button gives you, but
|
||||
automatic when nothing was hand-edited.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] == [5, -1, 2, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""A saved mapping's tray IDs only mean something relative to the
|
||||
printer they were resolved against. Reprinting the same archive on a
|
||||
*different* printer must not inherit it — tray 5 on printer A can
|
||||
hold a completely different spool than tray 5 on printer B.
|
||||
"""
|
||||
origin_printer = await printer_factory()
|
||||
other_printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": other_printer.id,
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""A model-based item (no fixed printer_id) can't know in advance
|
||||
which printer the scheduler will pick, so a saved mapping resolved
|
||||
against one specific printer must never be inherited here either.
|
||||
"""
|
||||
origin_printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"target_model": "X1C",
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""An explicit ams_mapping in the request (e.g. from the filament
|
||||
mapping panel) must take priority over the archive's saved slicer
|
||||
pick — the fallback only fires when the caller sent nothing at all.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"ams_mapping": [9, -1, 1, -1],
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] == [9, -1, 1, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""extra_data present but without a slicer_ams_mapping key (the
|
||||
common case — most archives have other metadata but no saved slicer
|
||||
mapping) must not accidentally trip the fallback."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(extra_data={"filament_slots": []})
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Force-color-match overrides are the caller asking the scheduler to
|
||||
match strictly against the printer's live trays, and they are only ever
|
||||
applied inside `_compute_ams_mapping_for_printer` — the function a
|
||||
stored mapping makes the scheduler skip. Inheriting the saved mapping
|
||||
here would silently retire the strictness that was just requested
|
||||
(#2700 review).
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"filament_overrides": [
|
||||
{"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
|
||||
],
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_plain_overrides_still_allow_the_archive_fallback(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""Only force_color_match stands the fallback down. A plain preference
|
||||
override is a filament swap, not a request for live colour matching, so
|
||||
the saved mapping is still the best starting point.
|
||||
"""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
|
||||
)
|
||||
|
||||
data = {
|
||||
"printer_id": printer.id,
|
||||
"archive_id": archive.id,
|
||||
"filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
|
||||
}
|
||||
response = await async_client.post("/api/v1/queue/", json=data)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["ams_mapping"] == [5, -1, 2, -1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
|
||||
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
|
||||
):
|
||||
"""`archive_has_slicer_ams_mapping` drives a badge that claims the
|
||||
print reuses the slicer's exact trays. Global tray IDs mean nothing on
|
||||
another printer, so the flag must be false for a row targeting one —
|
||||
otherwise the badge is there while nothing is reused (#2700 review).
|
||||
"""
|
||||
origin_printer = await printer_factory()
|
||||
other_printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
|
||||
)
|
||||
|
||||
own = await async_client.post(
|
||||
"/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
|
||||
)
|
||||
assert own.status_code == 200
|
||||
assert own.json()["archive_has_slicer_ams_mapping"] is True
|
||||
|
||||
foreign = await async_client.post(
|
||||
"/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
|
||||
)
|
||||
assert foreign.status_code == 200
|
||||
assert foreign.json()["archive_has_slicer_ams_mapping"] is False
|
||||
|
||||
# Model-based: the scheduler hasn't picked a printer yet, so the
|
||||
# mapping is not reused there either.
|
||||
model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
|
||||
assert model_based.status_code == 200
|
||||
assert model_based.json()["archive_has_slicer_ams_mapping"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_with_plate_id(
|
||||
|
|
|
|||
|
|
@ -3855,8 +3855,10 @@ class TestSetChamberTemperatureAPI:
|
|||
class TestSetFanSpeedAPI:
|
||||
"""Integration tests for POST /printers/{id}/fan-speed (#1661).
|
||||
|
||||
The fan-id mapping (part->1, aux->2, chamber->3) is the critical
|
||||
correctness gate — wrong mapping would target the wrong physical fan.
|
||||
The fan-id mapping (part->1, aux->2, chamber->3, aux2->10) is the
|
||||
critical correctness gate — wrong mapping would target the wrong
|
||||
physical fan. "aux2" (M106 P10) is the optional left auxiliary part
|
||||
cooling fan on P2S/X2D.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -3879,13 +3881,17 @@ class TestSetFanSpeedAPI:
|
|||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
"fan_name,expected_fan_id",
|
||||
[("part", 1), ("aux", 2), ("chamber", 3)],
|
||||
[("part", 1), ("aux", 2), ("chamber", 3), ("aux2", 10)],
|
||||
)
|
||||
async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
|
||||
"""Verify each fan name maps to the correct hardware fan-id."""
|
||||
printer = await printer_factory(name="P", model="X1C")
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_fan_speed.return_value = True
|
||||
# aux2 is presence-gated on the printer reporting airduct part 10, so
|
||||
# give the mock a reported speed. Set explicitly rather than leaning on
|
||||
# MagicMock's auto-attribute, which would satisfy the gate by accident.
|
||||
mock_client.state.left_aux_fan_speed = 0
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
|
||||
|
|
@ -3893,6 +3899,39 @@ class TestSetFanSpeedAPI:
|
|||
called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
|
||||
assert called_fan_id == expected_fan_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_aux2_rejected_when_printer_has_no_left_aux_fan(self, async_client: AsyncClient, printer_factory):
|
||||
"""A printer that never reports airduct part 10 must not be sent M106 P10.
|
||||
|
||||
Without the gate the endpoint accepted aux2 for every model, so a POST
|
||||
against an A1 would fire a command for hardware that does not exist.
|
||||
"""
|
||||
printer = await printer_factory(name="P", model="A1")
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_fan_speed.return_value = True
|
||||
mock_client.state.left_aux_fan_speed = None
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=aux2&speed=50")
|
||||
assert response.status_code == 400
|
||||
assert "left auxiliary fan" in response.json()["detail"]
|
||||
mock_client.set_fan_speed.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_other_fans_unaffected_by_the_aux2_gate(self, async_client: AsyncClient, printer_factory):
|
||||
"""The gate is aux2-only — a base P2S can still drive its built-in fans."""
|
||||
printer = await printer_factory(name="P", model="P2S")
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_fan_speed.return_value = True
|
||||
mock_client.state.left_aux_fan_speed = None
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
for fan_name in ("part", "aux", "chamber"):
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=50")
|
||||
assert response.status_code == 200, fan_name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -3911,6 +3950,36 @@ class TestSetFanSpeedAPI:
|
|||
_called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
|
||||
assert called_pwm == expected_pwm
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize(
|
||||
"model,expected_label",
|
||||
[
|
||||
("P2S", "Exhaust fan"),
|
||||
("X2D", "Exhaust fan"),
|
||||
("X1C", "Chamber fan"),
|
||||
("P1S", "Chamber fan"),
|
||||
("H2D", "Chamber fan"),
|
||||
],
|
||||
)
|
||||
async def test_chamber_fan_message_matches_model_label(
|
||||
self, async_client: AsyncClient, printer_factory, model, expected_label
|
||||
):
|
||||
"""The success toast must use the same name as the printer card badge.
|
||||
|
||||
On P2S/X2D the big_fan2 fan is labelled "Exhaust"; everywhere else it
|
||||
stays "Chamber". A mismatch means the user clicks "Exhaust" and gets
|
||||
told "Chamber fan set to N%".
|
||||
"""
|
||||
printer = await printer_factory(name="P", model=model)
|
||||
mock_client = MagicMock()
|
||||
mock_client.set_fan_speed.return_value = True
|
||||
with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
|
||||
mock_pm.get_client.return_value = mock_client
|
||||
response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=chamber&speed=50")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["message"] == f"{expected_label} set to 50%"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
|
||||
|
|
|
|||
|
|
@ -1393,3 +1393,430 @@ class TestProjectListEditableFields:
|
|||
result = response.json()
|
||||
assert result["tags"] is None
|
||||
assert result["due_date"] is None
|
||||
|
||||
|
||||
class TestProjectFileProgress:
|
||||
"""Per-file print progress inside a project (#1897).
|
||||
|
||||
Covers GET /projects/{id}/file-progress (attribution: library_file_id →
|
||||
content hash → filename, completed runs only, project-scoped), the
|
||||
target_sets field round-trip, and the add-to-queue project inheritance
|
||||
that feeds the attribution chain.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def project_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_project(**kwargs):
|
||||
from backend.app.models.project import Project
|
||||
|
||||
_counter[0] += 1
|
||||
defaults = {"name": f"Progress Project {_counter[0]}"}
|
||||
defaults.update(kwargs)
|
||||
project = Project(**defaults)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
return _create_project
|
||||
|
||||
@pytest.fixture
|
||||
async def folder_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_folder(**kwargs):
|
||||
from backend.app.models.library import LibraryFolder
|
||||
|
||||
_counter[0] += 1
|
||||
defaults = {"name": f"ProgressFolder {_counter[0]}"}
|
||||
defaults.update(kwargs)
|
||||
folder = LibraryFolder(**defaults)
|
||||
db_session.add(folder)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(folder)
|
||||
return folder
|
||||
|
||||
return _create_folder
|
||||
|
||||
@pytest.fixture
|
||||
async def file_factory(self, db_session):
|
||||
_counter = [0]
|
||||
|
||||
async def _create_file(**kwargs):
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
_counter[0] += 1
|
||||
counter = _counter[0]
|
||||
defaults = {
|
||||
"filename": f"plate_{counter}.gcode.3mf",
|
||||
"file_path": f"library/plate_{counter}.gcode.3mf",
|
||||
"file_size": 1024,
|
||||
"file_type": "3mf",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
lib_file = LibraryFile(**defaults)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
return lib_file
|
||||
|
||||
return _create_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_counts_by_library_file_id(
|
||||
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
|
||||
):
|
||||
"""Runs stamped with library_file_id count toward that file even when
|
||||
the archive's filename differs (rename after dispatch)."""
|
||||
project = await project_factory()
|
||||
folder = await folder_factory(project_id=project.id)
|
||||
file_a = await file_factory(folder_id=folder.id)
|
||||
file_b = await file_factory(folder_id=folder.id)
|
||||
printer = await printer_factory()
|
||||
|
||||
for _ in range(2):
|
||||
await archive_factory(
|
||||
printer.id,
|
||||
project_id=project.id,
|
||||
library_file_id=file_a.id,
|
||||
filename="renamed_on_dispatch.gcode.3mf",
|
||||
)
|
||||
await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
|
||||
assert response.status_code == 200
|
||||
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
|
||||
assert counts == {file_a.id: 2, file_b.id: 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_hash_and_filename_fallback(
|
||||
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
|
||||
):
|
||||
"""Historical archives without library_file_id match by content hash,
|
||||
then by filename."""
|
||||
project = await project_factory()
|
||||
folder = await folder_factory(project_id=project.id)
|
||||
hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
|
||||
named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
|
||||
printer = await printer_factory()
|
||||
|
||||
# Hash match despite a different filename
|
||||
await archive_factory(
|
||||
printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
|
||||
)
|
||||
# Filename match with no hash on either side
|
||||
await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
|
||||
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
|
||||
assert counts == {hashed_file.id: 1, named_file.id: 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_only_completed_runs_count(
|
||||
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
|
||||
):
|
||||
"""Failed runs and never-printed archives do not advance the count."""
|
||||
project = await project_factory()
|
||||
folder = await folder_factory(project_id=project.id)
|
||||
lib_file = await file_factory(folder_id=folder.id)
|
||||
printer = await printer_factory()
|
||||
|
||||
await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
|
||||
await archive_factory(
|
||||
printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
|
||||
)
|
||||
await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
|
||||
counts = {row["file_id"]: row["completed_count"] for row in response.json()}
|
||||
assert counts == {lib_file.id: 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_scoped_to_project(
|
||||
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
|
||||
):
|
||||
"""Runs of the same file outside the project (no project / another
|
||||
project) are excluded."""
|
||||
project = await project_factory()
|
||||
other_project = await project_factory()
|
||||
folder = await folder_factory(project_id=project.id)
|
||||
lib_file = await file_factory(folder_id=folder.id)
|
||||
printer = await printer_factory()
|
||||
|
||||
await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
|
||||
await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
|
||||
assert response.json() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unknown_project_404(self, async_client: AsyncClient):
|
||||
response = await async_client.get("/api/v1/projects/999999/file-progress")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_target_sets_roundtrip(self, async_client: AsyncClient):
|
||||
"""target_sets survives create, update, and explicit-null clearing."""
|
||||
create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
|
||||
assert create.status_code == 200
|
||||
project = create.json()
|
||||
assert project["target_sets"] == 10
|
||||
|
||||
update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
|
||||
assert update.status_code == 200, update.json()
|
||||
assert update.json()["target_sets"] == 4
|
||||
|
||||
cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
|
||||
assert cleared.json()["target_sets"] is None
|
||||
|
||||
untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
|
||||
assert untouched.json()["target_sets"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_add_to_queue_inherits_folder_project(
|
||||
self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
|
||||
):
|
||||
"""Queueing a file from a project-linked folder attributes the queue
|
||||
item (and thus the later archive) to that project; a root file stays
|
||||
unattributed."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
|
||||
project = await project_factory()
|
||||
folder = await folder_factory(project_id=project.id)
|
||||
|
||||
on_disk = tmp_path / "linked.gcode.3mf"
|
||||
on_disk.write_bytes(b"fake sliced content")
|
||||
linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
|
||||
|
||||
root_disk = tmp_path / "root.gcode.3mf"
|
||||
root_disk.write_bytes(b"fake sliced content")
|
||||
root_file = await file_factory(folder_id=None, file_path=str(root_disk))
|
||||
|
||||
response = await async_client.post(
|
||||
"/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["added"]) == 2
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
|
||||
PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
|
||||
)
|
||||
)
|
||||
projects_by_file = dict(result.all())
|
||||
assert projects_by_file[linked_file.id] == project.id
|
||||
assert projects_by_file[root_file.id] is None
|
||||
|
||||
|
||||
class TestSoftDeletedArchivesLeaveTheProject:
|
||||
"""Deleting a print removes it from its project, everywhere (#2731).
|
||||
|
||||
The default archive delete is soft (#1343): the files go, the row stays so
|
||||
global Quick Stats keeps counting its filament / time / cost. Nothing in the
|
||||
projects module filtered on that, so a deleted print stayed listed on the
|
||||
project with a thumbnail pointing at a file that no longer existed — and
|
||||
could not be unassigned, because the only unassign UI lives on the Archives
|
||||
page, which correctly hides it.
|
||||
|
||||
Unlike Quick Stats, project *counts* exclude it too. A project is a piece of
|
||||
work with a definite membership, not a lifetime total, so a project that
|
||||
lists one print must not claim two.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
async def project_factory(self, db_session):
|
||||
async def _create_project(**kwargs):
|
||||
from backend.app.models.project import Project
|
||||
|
||||
defaults = {"name": "Deleted Archive Project", "color": "#FF0000"}
|
||||
defaults.update(kwargs)
|
||||
project = Project(**defaults)
|
||||
db_session.add(project)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(project)
|
||||
return project
|
||||
|
||||
return _create_project
|
||||
|
||||
@pytest.fixture
|
||||
async def archive_factory(self, db_session):
|
||||
"""Archive + matching PrintLogEntry, as production always writes both."""
|
||||
|
||||
async def _create_archive(**kwargs):
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.print_log import PrintLogEntry
|
||||
|
||||
defaults = {
|
||||
"filename": "test.3mf",
|
||||
"file_path": "test/test.3mf",
|
||||
"file_size": 1000,
|
||||
"print_name": "Test Print",
|
||||
"status": "completed",
|
||||
"quantity": 1,
|
||||
"thumbnail_path": "test/thumb.png",
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
archive = PrintArchive(**defaults)
|
||||
db_session.add(archive)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(archive)
|
||||
|
||||
db_session.add(
|
||||
PrintLogEntry(
|
||||
archive_id=archive.id,
|
||||
print_name=archive.print_name,
|
||||
status=archive.status,
|
||||
filament_used_grams=10.0,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return archive
|
||||
|
||||
return _create_archive
|
||||
|
||||
@staticmethod
|
||||
async def _soft_delete(db_session, archive) -> int:
|
||||
"""Soft-delete *archive* and return its id.
|
||||
|
||||
The commit expires the instance, so reading an attribute off it
|
||||
afterwards is lazy IO outside the greenlet context (MissingGreenlet).
|
||||
Callers take the id from here instead.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
archive_id = archive.id
|
||||
archive.deleted_at = datetime.now(timezone.utc)
|
||||
await db_session.commit()
|
||||
return archive_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deleted_archive_is_not_listed_on_the_project(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""The reported symptom: a card with a broken preview image."""
|
||||
project = await project_factory()
|
||||
await archive_factory(project_id=project.id, print_name="Kept")
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/archives")
|
||||
assert response.status_code == 200
|
||||
assert [a["print_name"] for a in response.json()] == ["Kept"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deleted_archive_is_not_a_preview_on_the_project_card(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""The overview page renders these as thumbnails too, so it broke there
|
||||
as well — not just on the detail page."""
|
||||
project = await project_factory()
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get("/api/v1/projects/")
|
||||
assert response.status_code == 200
|
||||
row = next(p for p in response.json() if p["id"] == project.id)
|
||||
assert row["archives"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_project_counts_exclude_the_deleted_archive(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""The list shows one print, so the count must say one."""
|
||||
project = await project_factory()
|
||||
await archive_factory(project_id=project.id, print_name="Kept")
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get("/api/v1/projects/")
|
||||
row = next(p for p in response.json() if p["id"] == project.id)
|
||||
assert row["archive_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_project_stats_exclude_the_deleted_archive(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""Deliberate divergence from #1343: the contribution leaves the project
|
||||
even though it stays in global Quick Stats."""
|
||||
project = await project_factory()
|
||||
await archive_factory(project_id=project.id, print_name="Kept")
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert response.status_code == 200
|
||||
stats = response.json()["stats"]
|
||||
assert stats["total_archives"] == 1
|
||||
assert stats["total_filament_grams"] == pytest.approx(10.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deleted_archive_is_not_in_the_project_timeline(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""A timeline entry for it links to an archive that 404s when clicked."""
|
||||
project = await project_factory()
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.get(f"/api/v1/projects/{project.id}/timeline")
|
||||
assert response.status_code == 200
|
||||
assert not any(e.get("description") == "Deleted" for e in response.json())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_live_archive_is_untouched_by_all_of_this(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory
|
||||
):
|
||||
"""The filter must not cost a project its actual prints."""
|
||||
project = await project_factory()
|
||||
await archive_factory(project_id=project.id, print_name="Kept")
|
||||
|
||||
listing = await async_client.get(f"/api/v1/projects/{project.id}/archives")
|
||||
assert [a["print_name"] for a in listing.json()] == ["Kept"]
|
||||
|
||||
stats = await async_client.get(f"/api/v1/projects/{project.id}")
|
||||
assert stats.json()["stats"]["total_archives"] == 1
|
||||
|
||||
row = next(p for p in (await async_client.get("/api/v1/projects/")).json() if p["id"] == project.id)
|
||||
assert row["archive_count"] == 1
|
||||
assert len(row["archives"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_unassigning_an_already_orphaned_link_still_works(
|
||||
self, async_client: AsyncClient, project_factory, archive_factory, db_session
|
||||
):
|
||||
"""The listings hide it, but the API must still be able to clear the
|
||||
link — that is the repair path for rows written before this fix."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
||||
project = await project_factory()
|
||||
gone = await archive_factory(project_id=project.id, print_name="Deleted")
|
||||
gone_id = await self._soft_delete(db_session, gone)
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/projects/{project.id}/remove-archives", json={"archive_ids": [gone_id]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
db_session.expire_all()
|
||||
result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
|
||||
assert result.scalar_one() is None
|
||||
|
|
|
|||
|
|
@ -100,14 +100,17 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
|
|||
|
||||
# base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
|
||||
# video matches by name (strategy 1). .mp4 → no background conversion task.
|
||||
video_bytes = b"fake-timelapse-video-bytes"
|
||||
matched = {
|
||||
"name": "test_print.gcode.mp4",
|
||||
"path": "/timelapse/test_print.gcode.mp4",
|
||||
"is_directory": False,
|
||||
"size": 4096,
|
||||
# Must equal len(video_bytes): the download is checked against the
|
||||
# listing, and the file is re-listed afterwards to confirm the printer
|
||||
# has stopped writing it (#2704).
|
||||
"size": len(video_bytes),
|
||||
"mtime": None,
|
||||
}
|
||||
video_bytes = b"fake-timelapse-video-bytes"
|
||||
|
||||
with (
|
||||
patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
|
||||
|
|
@ -119,6 +122,9 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
|
|||
"backend.app.services.bambu_ftp.download_file_bytes_async",
|
||||
AsyncMock(return_value=video_bytes),
|
||||
) as mock_download,
|
||||
# A successful attach now removes the printer's copy (#2704); without
|
||||
# this the endpoint would open a real FTP connection to the fixture IP.
|
||||
patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
|
||||
):
|
||||
response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
|
||||
|
||||
|
|
@ -127,6 +133,7 @@ async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
|
|||
assert data["status"] == "attached"
|
||||
assert data["filename"] == "test_print.gcode.mp4"
|
||||
mock_download.assert_awaited_once()
|
||||
mock_delete.assert_awaited_once()
|
||||
|
||||
# The write happened in the route's fresh session; confirm it was committed
|
||||
# by re-reading the row on the separate test session.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,27 @@ import pytest
|
|||
from backend.app.services.bambu_cloud import BambuCloudService
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_csrf_handshake():
|
||||
"""Keep the CSRF pre-flight off the network for every test in this module.
|
||||
|
||||
``verify_totp`` fetches a CSRF token from the ``bambulab.com`` web origin
|
||||
before posting the code (#2696), and returns early without posting when it
|
||||
cannot get one. The tests below patch only ``post``, so that GET went out
|
||||
over the real network: it succeeded on any machine that could reach
|
||||
bambulab.com — which is why this file passed locally — and returned a
|
||||
tokenless 403 on a CI runner, where six tests then failed asserting on a
|
||||
``post`` that never happened.
|
||||
|
||||
The handshake itself is covered end to end in
|
||||
``tests/unit/test_cloud_totp_csrf.py``, including the no-token path, so
|
||||
stubbing it here removes a network dependency rather than any coverage.
|
||||
"""
|
||||
with patch.object(BambuCloudService, "_fetch_csrf_token", new_callable=AsyncMock) as fetch:
|
||||
fetch.return_value = "csrf-token-for-tests"
|
||||
yield fetch
|
||||
|
||||
|
||||
class TestBambuCloudLogin:
|
||||
"""Test login flow detection (email vs TOTP)."""
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue