mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-03 04:31:28 +02:00
Every file the internal slicer produced came back with filament_colour = #00AE42 whatever filament was picked: a green plate thumbnail, green metadata, and a "Color mismatch" in the Print dialog against the AMS slot the job had just been correctly mapped to. A colour is not a property of a filament preset in either slicer. It belongs to the project, and Bambu Studio and OrcaSlicer set it from the plate in their GUIs -- so nothing was attached to the preset Bambuddy sends by name, and the CLI fell back to its own compiled-in default, which is Bambu green. None of the shipped BBL filament profiles define filament_colour; the whole tree has zero occurrences. default_filament_colour is not the answer on its own. Measured against a 02.08.02.61 sidecar, a profile carrying only that still slices to filament_colour ["#00AE42"] -- Bambu Studio consumes it in the GUI when a project is created, not in --load-filaments. So it is read and rewritten as filament_colour, which the same sidecar does honour: the reporter's exact triplet returns ["#E8B00C"] when patched this way, and the colour lands in both project_settings.config and slice_info.config, which is what the thumbnail and the AMS mapping actually read. Each filament row in the slice dialog gets a colour control, and the resolved value flows through a chain: the user's pick, then the preset's own default_filament_colour, then the colour that slot was designed with in the source 3MF. A slot with none of the three is left untouched rather than given a guess. The designed colour is read from project_settings.config, not slice_info.config. The latter records what the file was last sliced with, which for a source that never carried a colour is #00AE42 itself -- measured -- so using it would have been circular. The control is offered on single-filament sources too, because an STL, and equally a mesh-only 3MF exported from CAD, has no colour anywhere else to inherit. That is the case this exists for, and it took three shapes to make it visible: a swatch in the label row was identical to the read-only dot multi-colour rows have carried for releases, and adding the hex beside it only made it look like a caption. It now sits beside the dropdown, styled like it and the same height, with the swatch and hex wrapped in one label bound to the input so a click anywhere on it opens the picker. An untouched slot with no designed colour submits an empty string rather than the control's displayed default. A sent colour outranks the preset's own, so pinning the placeholder would silently discard the real colour of an imported OrcaSlicer profile that carries one. Slicer Pipelines pick up the same chain without carrying a colour of their own. Also adds a warning for a defect found while investigating this: a filament preset whose name the sidecar's bundle cannot resolve is not rejected. The CLI inherits nothing, falls back to its defaults for every field, and returns a well-formed success -- measured, an unresolvable name slices as filament_type ["PLA"] at nozzle_temperature ["200"] with filament_ids [""] and filament_vendor ["(Undefined)"], so a PETG preset a sidecar image predates prints at PLA temperatures with no diagnostic anywhere. Both signals are required together, which keeps it off the two legitimate lookalikes: a hand-written profile that never named a vendor still carries a real filament id, and a user's own cloud preset carries a vendor while legitimately having no bundled id. The file is kept rather than refused, unlike the missing start G-code of whoever can see the temperatures.
8802 lines
309 KiB
TypeScript
8802 lines
309 KiB
TypeScript
import type { ArchivePlatesResponse, LibraryFilePlatesResponse } from '../types/plates';
|
|
|
|
const API_BASE = '/api/v1';
|
|
|
|
export class ApiError extends Error {
|
|
status: number;
|
|
/** Stable error code from a structured backend detail (`{code, message}`).
|
|
* Frontend uses this to look up an i18n key instead of showing the raw
|
|
* English fallback. Null when the backend returned a plain-string detail. */
|
|
code: string | null;
|
|
/** Full structured detail object when the backend returned `{code, ...}`
|
|
* with additional fields (e.g. the deficit list for 409s on queue
|
|
* start, #1496). Null for plain-string or array-shaped details. */
|
|
detail: Record<string, unknown> | null;
|
|
constructor(
|
|
message: string,
|
|
status: number,
|
|
code: string | null = null,
|
|
detail: Record<string, unknown> | null = null,
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.code = code;
|
|
this.detail = detail;
|
|
}
|
|
}
|
|
|
|
// Auth token storage
|
|
// By default tokens are stored in sessionStorage (tab-scoped, cleared on close).
|
|
// When the token originates from the ?token= URL param (kiosk bootstrap), it is
|
|
// additionally persisted in localStorage so the kiosk survives page reloads.
|
|
// 'persistent' also writes to localStorage so the token survives tab close
|
|
// (used by Remember Me and the ?token= kiosk bootstrap).
|
|
let authToken: string | null =
|
|
sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
|
|
|
|
export type TokenPersistence = 'session' | 'persistent';
|
|
|
|
export function setAuthToken(token: string | null, persistence: TokenPersistence = 'session') {
|
|
authToken = token;
|
|
try {
|
|
if (token) {
|
|
sessionStorage.setItem('auth_token', token);
|
|
} else {
|
|
sessionStorage.removeItem('auth_token');
|
|
}
|
|
} catch (err) {
|
|
// Storage unavailable (quota exceeded, private mode): in-memory token still works for this tab.
|
|
console.warn('setAuthToken: sessionStorage unavailable, token kept in-memory only', err);
|
|
}
|
|
try {
|
|
if (!token) {
|
|
localStorage.removeItem('auth_token');
|
|
} else if (persistence === 'persistent') {
|
|
localStorage.setItem('auth_token', token);
|
|
}
|
|
} catch (err) {
|
|
console.warn('setAuthToken: localStorage operation failed', err);
|
|
}
|
|
}
|
|
|
|
export function getAuthToken(): string | null {
|
|
return authToken;
|
|
}
|
|
|
|
// Stream token for image/video URLs loaded via <img>/<video> tags
|
|
// (these can't send Authorization headers, so a query param token is used)
|
|
let streamToken: string | null = null;
|
|
|
|
export function setStreamToken(token: string | null) {
|
|
streamToken = token;
|
|
}
|
|
|
|
export function getStreamToken(): string | null {
|
|
return streamToken;
|
|
}
|
|
|
|
/** Append the stream token to a URL if available (for <img>/<video> src). */
|
|
export function withStreamToken(url: string): string {
|
|
if (!streamToken) return url;
|
|
const sep = url.includes('?') ? '&' : '?';
|
|
return `${url}${sep}token=${encodeURIComponent(streamToken)}`;
|
|
}
|
|
|
|
function parseContentDispositionFilename(header: string | null): string | null {
|
|
if (!header) return null;
|
|
// RFC 5987: filename*=utf-8''percent-encoded-name
|
|
const rfc5987Match = header.match(/filename\*=(?:UTF-8|utf-8)''(.+?)(?:;|$)/);
|
|
if (rfc5987Match) {
|
|
try { return decodeURIComponent(rfc5987Match[1]); } catch { /* fall through */ }
|
|
}
|
|
// Standard: filename="name" or filename=name
|
|
const standardMatch = header.match(/filename="?([^";\n]+)"?/);
|
|
return standardMatch?.[1] || null;
|
|
}
|
|
|
|
function buildSlicerUrlFilename(filename: string): string {
|
|
const safe = filename.replace(/[/\\?#]/g, '_');
|
|
return safe.toLowerCase().endsWith('.3mf') ? safe : `${safe}.3mf`;
|
|
}
|
|
|
|
async function request<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<T> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...options.headers as Record<string, string>,
|
|
};
|
|
|
|
// Add auth token if available
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
...options,
|
|
cache: 'no-store', // Prevent browser caching of API responses
|
|
credentials: 'include', // Required for HttpOnly cookies (e.g. 2fa_challenge)
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
const detail = error.detail;
|
|
let message: string;
|
|
let code: string | null = null;
|
|
if (typeof detail === 'string') {
|
|
message = detail;
|
|
} else if (Array.isArray(detail)) {
|
|
// FastAPI 422 shape: each entry has `msg` like "Value error, <real msg>".
|
|
// Strip the prefix and join. Fall back to raw JSON if every entry has an
|
|
// empty msg (defensive — shouldn't happen with stock Pydantic, but the
|
|
// previous fallback masked the real cause as a bare "HTTP 422" toast).
|
|
const joined = detail
|
|
.map((e: { msg?: string }) => (e.msg ?? '').replace(/^Value error,\s*/i, ''))
|
|
.filter(Boolean)
|
|
.join('; ');
|
|
message = joined || JSON.stringify(detail) || `HTTP ${response.status}`;
|
|
} else if (detail && typeof detail === 'object') {
|
|
// Structured detail `{code, message, ...}` — frontend uses the code
|
|
// to pick an i18n key, message is the English fallback, any extra
|
|
// fields land on ApiError.detail (e.g. `deficit` for #1496).
|
|
code = typeof detail.code === 'string' ? detail.code : null;
|
|
message = typeof detail.message === 'string' ? detail.message : `HTTP ${response.status}`;
|
|
} else {
|
|
message = `HTTP ${response.status}`;
|
|
}
|
|
const structuredDetail = detail && typeof detail === 'object' && !Array.isArray(detail)
|
|
? (detail as Record<string, unknown>)
|
|
: null;
|
|
|
|
// Handle 401 Unauthorized - only clear token if it's actually invalid
|
|
// Don't clear on "Authentication required" which might be a timing issue
|
|
if (response.status === 401) {
|
|
const invalidTokenMessages = [
|
|
'Could not validate credentials',
|
|
'Token has expired',
|
|
'User not found or inactive',
|
|
'Invalid API key',
|
|
'API key has expired',
|
|
];
|
|
if (invalidTokenMessages.some(m => message.includes(m))) {
|
|
setAuthToken(null);
|
|
// Notify AuthContext so the protected route guard re-evaluates and
|
|
// redirects to /login on the same tab — without this, AuthContext.user
|
|
// stays cached and the tab silently fails every request until a manual
|
|
// refresh remounts AuthProvider (#1698, reported by @TCL987).
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('auth:expired'));
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new ApiError(message, response.status, code, structuredDetail);
|
|
}
|
|
|
|
// Handle empty responses (204 No Content, etc.)
|
|
const contentLength = response.headers.get('content-length');
|
|
if (response.status === 204 || contentLength === '0') {
|
|
return undefined as T;
|
|
}
|
|
|
|
return await response.json();
|
|
}
|
|
|
|
/** Upload a CSV to the spool import endpoint (#1576). Multipart, so it bypasses
|
|
* `request<T>()` (which sends JSON): the browser must set the form-data
|
|
* boundary itself. `dryRun` toggles preview-only vs. real import. */
|
|
async function uploadSpoolsCsv<T>(file: File, dryRun: boolean): Promise<T> {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
const response = await fetch(`${API_BASE}/inventory/spools/import${dryRun ? '?dry_run=true' : ''}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: form,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
// detail may be a plain string or a structured {code, message} object
|
|
// (e.g. the 413 too-large response). Surface the human message either way.
|
|
const detail = error?.detail;
|
|
const message = typeof detail === 'string' ? detail : detail?.message;
|
|
throw new Error(message || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
// Camera diagnostic result (#1395 follow-up). Returned by
|
|
// POST /printers/{id}/camera/diagnose; the frontend modal renders one
|
|
// row per stage and looks up the summary code in i18n for the user-
|
|
// facing remediation hint.
|
|
export interface CameraDiagnoseStage {
|
|
name: 'tcp_reachable' | 'first_frame' | 'live_stream_active';
|
|
status: 'ok' | 'failed' | 'skipped';
|
|
duration_ms: number;
|
|
code: string | null;
|
|
}
|
|
|
|
export interface CameraDiagnoseResult {
|
|
printer_id: number;
|
|
protocol: 'rtsp' | 'chamber_image';
|
|
port: number;
|
|
// 'default' = historical X1/H2 tuning. Anything else = this model has
|
|
// an override entry in backend/app/services/camera_profiles.py.
|
|
profile: string;
|
|
overall_status: 'ok' | 'failed';
|
|
stages: CameraDiagnoseStage[];
|
|
// i18n key under `camera.diagnose.summary.*`.
|
|
summary_code: string;
|
|
}
|
|
|
|
// Connection diagnostic (GET /printers/{id}/diagnostic and
|
|
// POST /printers/diagnostic). Each check's `id` + `status` resolve a
|
|
// localized title/fix under `diagnostic.check.*`; `params` interpolate it.
|
|
export type DiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
|
|
|
|
export interface DiagnosticCheck {
|
|
id:
|
|
| 'port_mqtt'
|
|
| 'port_ftps'
|
|
| 'port_rtsps'
|
|
| 'network_mode'
|
|
| 'subnet'
|
|
| 'mqtt_auth'
|
|
| 'developer_mode';
|
|
status: DiagnosticStatus;
|
|
params: Record<string, string | number>;
|
|
}
|
|
|
|
export interface PrinterDiagnosticResult {
|
|
printer_id: number | null;
|
|
ip_address: string;
|
|
overall: 'ok' | 'warnings' | 'problems';
|
|
checks: DiagnosticCheck[];
|
|
}
|
|
|
|
// --- Log-health scan: self-service triage on the System page + bug reporter.
|
|
// The backend matches recent logs against a curated known-issue catalog;
|
|
// human-readable cause/fix text is rendered from i18n keys keyed by signature_id.
|
|
export type LogFindingSeverity = 'error' | 'warning';
|
|
export type LogFindingCategory = 'layer8' | 'environment' | 'bug';
|
|
|
|
export interface LogFinding {
|
|
signature_id: string;
|
|
severity: LogFindingSeverity;
|
|
category: LogFindingCategory;
|
|
wiki_anchor: string;
|
|
count: number;
|
|
first_seen: string;
|
|
last_seen: string;
|
|
sample: string;
|
|
}
|
|
|
|
export interface SystemHealthResult {
|
|
findings: LogFinding[];
|
|
scanned_entries: number;
|
|
log_available: boolean;
|
|
summary: {
|
|
total: number;
|
|
layer8: number;
|
|
environment: number;
|
|
bug: number;
|
|
};
|
|
}
|
|
|
|
// Long-lived camera tokens (#1108). The `token` field is populated only on the
|
|
// create response — listing endpoints set it to null because the plaintext
|
|
// value is shown to the user exactly once.
|
|
//
|
|
// 'camera_stream' reaches the video endpoints only. 'camwall' additionally
|
|
// reaches the read-only Cam Wall feed, which names the printers (#2531), so it
|
|
// is a separate scope rather than a widening of tokens already in the wild.
|
|
export type LongLivedTokenScope = 'camera_stream' | 'camwall' | 'overlay';
|
|
|
|
export interface LongLivedCameraToken {
|
|
id: number;
|
|
user_id: number;
|
|
name: string;
|
|
scope: LongLivedTokenScope;
|
|
lookup_prefix: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
last_used_at: string | null;
|
|
token: string | null;
|
|
}
|
|
|
|
// One row of the token-authenticated Cam Wall feed (#2531). Deliberately
|
|
// smaller than PrinterStatus: no serial, no IP, no print filename — a kiosk URL
|
|
// is not a secret, so the payload behind it must not be either.
|
|
export interface CamWallPrinter {
|
|
id: number;
|
|
name: string;
|
|
camera_rotation: number;
|
|
connected: boolean;
|
|
state: string | null;
|
|
progress: number | null;
|
|
remaining_time: number | null;
|
|
layer_num: number | null;
|
|
total_layers: number | null;
|
|
hms_errors: HMSError[];
|
|
}
|
|
|
|
// Streaming-overlay feed (#2613). The subset of print state the /overlay page
|
|
// draws for one printer, served behind an `overlay`-scoped token so OBS embeds
|
|
// with no login session can read it. Unlike CamWallPrinter this names the file
|
|
// being printed (the overlay shows the part on screen).
|
|
export interface OverlayStatus {
|
|
id: number;
|
|
name: string;
|
|
camera_rotation: number;
|
|
connected: boolean;
|
|
state: string | null;
|
|
current_print: string | null;
|
|
gcode_file: string | null;
|
|
progress: number | null;
|
|
remaining_time: number | null;
|
|
layer_num: number | null;
|
|
total_layers: number | null;
|
|
stg_cur_name: string | null;
|
|
// Nozzle / bed / chamber readings for the overlay's temperature fields
|
|
// (#1422). Only the keys a viewer is shown; chamber is absent on models
|
|
// without a real sensor.
|
|
temperatures: Record<string, number>;
|
|
time_format: 'system' | '12h' | '24h';
|
|
}
|
|
|
|
// Printer types
|
|
export interface Printer {
|
|
id: number;
|
|
name: string;
|
|
serial_number: string;
|
|
ip_address: string;
|
|
// Optional because the backend only returns access_code when the caller has
|
|
// PRINTERS_UPDATE — Admin / Operator JWTs or auth-disabled mode. Viewers and
|
|
// API keys receive a Printer without this field.
|
|
access_code?: string;
|
|
model: string | null;
|
|
location: string | null; // Group/location name
|
|
nozzle_count: number; // 1 or 2, auto-detected from MQTT
|
|
// Model is sold with both Standard and High Flow nozzles, so a K-profile's
|
|
// flow type is a real choice. Derived from the model, not the nozzle count —
|
|
// only the A-series has a single variant.
|
|
supports_nozzle_flow_type: boolean;
|
|
is_active: boolean;
|
|
auto_archive: boolean;
|
|
external_camera_url: string | null;
|
|
external_camera_type: string | null; // "mjpeg", "rtsp", "snapshot"
|
|
external_camera_enabled: boolean;
|
|
external_camera_snapshot_url: string | null; // optional single-frame override (#1177)
|
|
camera_rotation: number; // 0, 90, 180, 270 degrees
|
|
plate_detection_enabled: boolean; // Check plate before print
|
|
plate_detection_roi?: PlateDetectionROI; // ROI for plate detection
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface HMSError {
|
|
code: string;
|
|
attr: number; // Attribute value for constructing wiki URL
|
|
module: number;
|
|
severity: number; // 1=fatal, 2=serious, 3=common, 4=info
|
|
actions?: string[]; // List of user-facing action keys (e.g. "CHECK_FILAMENT")
|
|
job_id?: string; // Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
|
|
// Canonical hex identifier the firmware matches against — 8 chars for
|
|
// print_error-sourced faults, 16 chars for hms[]-array-sourced faults. Send
|
|
// this back as HmsActionBody.print_error so we don't truncate the 64-bit
|
|
// identifier into the silent-rejection short code (#1830).
|
|
full_code?: string;
|
|
// The backend's resolved catalogue sentence for this fault (#2926). English
|
|
// only, and null when the catalogue does not cover the code. Resolved with the
|
|
// same lookup order this file's consumers use (full_code, then the G1_G4
|
|
// collapse), so it agrees with what HMSErrorModal renders — the modal still
|
|
// resolves its own text, and this is here for parity with the API.
|
|
description?: string | null;
|
|
}
|
|
|
|
export interface HMSActionBody {
|
|
print_error: string; // HMS error code (e.g. "05000070")
|
|
action: string; // "HMS action to execute (e.g. 'resume_after_error')"
|
|
job_id: string | null; // Optional job ID for context (if applicable)
|
|
}
|
|
|
|
export interface AMSTray {
|
|
id: number;
|
|
tray_color: string | null;
|
|
tray_type: string | null;
|
|
tray_sub_brands: string | null; // Full name like "PLA Basic", "PETG HF"
|
|
tray_id_name: string | null; // Bambu filament ID like "A00-Y2" (can decode to color)
|
|
tray_info_idx: string | null; // Filament preset ID like "GFA00" - maps to cloud setting_id
|
|
remain: number;
|
|
k: number | null; // Pressure advance value (from tray or K-profile lookup)
|
|
cali_idx: number | null; // Calibration index for K-profile lookup
|
|
tag_uid: string | null; // RFID tag UID (any tag)
|
|
tray_uuid: string | null; // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
|
|
nozzle_temp_min: number | null; // Min nozzle temperature
|
|
nozzle_temp_max: number | null; // Max nozzle temperature
|
|
drying_temp: number | null; // RFID-recommended drying temp
|
|
drying_time: number | null; // RFID-recommended drying time (hours)
|
|
state: number | null; // AMS tray state: 9=empty, 10=spool present not loaded, 11=loaded
|
|
exists?: boolean | null; // Firmware tray_exist_bits: spool physically present (non-RFID → "?" not "Empty", #2527)
|
|
}
|
|
|
|
export interface AMSUnit {
|
|
id: number;
|
|
humidity: number | null;
|
|
temp: number | null;
|
|
is_ams_ht: boolean; // True for AMS-HT (single spool), False for regular AMS (4 spools)
|
|
tray: AMSTray[];
|
|
serial_number: string; // AMS unit serial number (from MQTT sn field)
|
|
sw_ver: string; // AMS firmware version (from get_version info.module ams/* entry)
|
|
dry_time: number; // Minutes remaining (0 = not drying, >0 = drying active)
|
|
dry_status: number; // 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
|
|
dry_sub_status: number; // 0=Off, 1=Heating, 2=Dehumidify
|
|
dry_sf_reason: number[]; // Cannot-dry reasons (1=InsufficientPower, 8=NeedPluginPower)
|
|
dry_target_temp: number | null; // Active-cycle target °C (Bambu does not echo)
|
|
dry_filament: string | null; // Active-cycle filament name we sent
|
|
module_type: string; // "ams", "n3f", "n3s"
|
|
}
|
|
|
|
export interface ScheduledDrying {
|
|
id: number;
|
|
printer_id: number;
|
|
ams_id: number;
|
|
temp: number;
|
|
duration_hours: number;
|
|
filament: string;
|
|
rotate_tray: boolean;
|
|
start_after: string | null; // UTC ISO with Z suffix, like the queue routes
|
|
status: string;
|
|
waiting_reason: string | null;
|
|
error_message: string | null;
|
|
created_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
}
|
|
|
|
export interface NozzleInfo {
|
|
// Two vocabularies live in this field, by printer generation. Legacy
|
|
// printers (X1/P1) report the nozzle MATERIAL -- "stainless_steel",
|
|
// "hardened_steel". H2-series report a FLOW code -- "HH01" (high flow),
|
|
// "HS01" (standard); measured on an H2D. utils/nozzleFlow reads only the
|
|
// latter and treats anything else as "unknown", which is what makes the
|
|
// material spelling harmless here.
|
|
nozzle_type: string;
|
|
nozzle_diameter: string; // e.g., "0.4"
|
|
}
|
|
|
|
export interface NozzleRackSlot {
|
|
id: number;
|
|
nozzle_type: string;
|
|
nozzle_diameter: string;
|
|
wear: number | null;
|
|
stat: number | null; // Nozzle status (e.g. mounted/docked)
|
|
max_temp: number;
|
|
serial_number: string;
|
|
filament_color: string; // RGBA hex ("00000000" = no filament)
|
|
filament_id: string;
|
|
filament_type: string; // Material type (e.g. "PLA", "PETG")
|
|
}
|
|
|
|
export interface PrintOptions {
|
|
// Core AI detectors
|
|
spaghetti_detector: boolean;
|
|
print_halt: boolean;
|
|
halt_print_sensitivity: string; // "low", "medium", "high" - spaghetti sensitivity
|
|
first_layer_inspector: boolean;
|
|
printing_monitor: boolean;
|
|
buildplate_marker_detector: boolean;
|
|
allow_skip_parts: boolean;
|
|
// Additional AI detectors (decoded from cfg bitmask)
|
|
nozzle_clumping_detector: boolean;
|
|
nozzle_clumping_sensitivity: string; // "low", "medium", "high"
|
|
pileup_detector: boolean;
|
|
pileup_sensitivity: string; // "low", "medium", "high"
|
|
airprint_detector: boolean;
|
|
airprint_sensitivity: string; // "low", "medium", "high"
|
|
auto_recovery_step_loss: boolean;
|
|
filament_tangle_detect: boolean;
|
|
}
|
|
|
|
export interface FilaSwitchState {
|
|
installed: boolean;
|
|
// Raw wire arrays, ordered **In-B first, then In-A** (BambuStudio's SwitchPos
|
|
// enum). in[] values are snow-encoded (bits 8-15 = AMS id, bits 0-7 = slot,
|
|
// -1 = empty); out[] values are the extruder each *outlet* terminates at, or
|
|
// 0xE when unset. Neither array says which inlet is currently routed to which
|
|
// outlet — that pairing is not reported. For per-AMS side information use
|
|
// PrinterStatus.ams_switch_inlet instead.
|
|
in_slots: number[];
|
|
out_extruders: number[];
|
|
stat: number;
|
|
info: number;
|
|
// Whether every AMS is bound to one of the switch's two inlets. Until it is,
|
|
// the switch cannot route a load anywhere and the printer has to be set up
|
|
// first ("Manual AMS Setup" on its screen).
|
|
ready: boolean;
|
|
}
|
|
|
|
// Which AMS slot one hotend is currently fed from. ams_id/slot_id are null when
|
|
// the hotend holds nothing. Keyed by extruder id ('0' = right, '1' = left).
|
|
// tray_now cannot answer this — it is one value for the whole printer, so on a
|
|
// dual-nozzle machine it names only one of the two loaded hotends.
|
|
export interface ExtruderSlot {
|
|
ams_id: number | null;
|
|
slot_id: number | null;
|
|
has_filament: boolean;
|
|
}
|
|
|
|
// Which FTS inlet an AMS is plumbed into: 'A' | 'B'.
|
|
export type FtsInlet = 'A' | 'B';
|
|
|
|
export interface PrinterStatus {
|
|
id: number;
|
|
name: string;
|
|
connected: boolean;
|
|
state: string | null;
|
|
current_print: string | null;
|
|
subtask_name: string | null;
|
|
current_archive_id: number | null;
|
|
current_plate_id: number | null;
|
|
gcode_file: string | null;
|
|
progress: number | null;
|
|
remaining_time: number | null;
|
|
layer_num: number | null;
|
|
total_layers: number | null;
|
|
temperatures: {
|
|
bed?: number;
|
|
bed_target?: number;
|
|
bed_heating?: boolean; // Actual heater state from MQTT
|
|
nozzle?: number;
|
|
nozzle_target?: number;
|
|
nozzle_heating?: boolean; // Actual heater state from MQTT
|
|
nozzle_2?: number; // Second nozzle for H2 series (dual nozzle)
|
|
nozzle_2_target?: number;
|
|
nozzle_2_heating?: boolean; // Actual heater state from MQTT
|
|
chamber?: number;
|
|
chamber_target?: number;
|
|
chamber_heating?: boolean; // Actual heater state from MQTT
|
|
} | null;
|
|
cover_url: string | null;
|
|
hms_errors: HMSError[];
|
|
ams: AMSUnit[];
|
|
ams_exists: boolean;
|
|
vt_tray: AMSTray[]; // Virtual tray / external spool(s)
|
|
store_to_sdcard: boolean; // Store sent files on SD card
|
|
timelapse: boolean; // Timelapse recording active
|
|
ipcam: boolean; // Live view enabled
|
|
wifi_signal: number | null; // WiFi signal strength in dBm
|
|
wired_network: boolean; // Ethernet connection detected
|
|
door_open: boolean; // Enclosure door open (models with a door sensor: X1/X1C/X1E/X2D/P2S/H2*)
|
|
// Indexed by EXTRUDER id: [0] is the right hotend, [1] the left. Measured
|
|
// on an H2D with 0.4 left / 0.6 right. Read it through the helpers in
|
|
// utils/amsHelpers rather than indexing it directly.
|
|
nozzles: NozzleInfo[];
|
|
nozzle_rack: NozzleRackSlot[]; // H2C 6-nozzle tool-changer rack
|
|
print_options: PrintOptions | null; // AI detection and print options
|
|
// Calibration stage tracking
|
|
stg_cur: number; // Current stage number (-1 = not calibrating)
|
|
stg_cur_name: string | null; // Human-readable current stage name
|
|
stg: number[]; // List of stage numbers in calibration sequence
|
|
// Air conditioning mode (0=cooling, 1=heating)
|
|
airduct_mode: number;
|
|
// Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
|
|
speed_level: number;
|
|
// Chamber light on/off
|
|
chamber_light: boolean;
|
|
// Active extruder for dual nozzle (0=right, 1=left)
|
|
active_extruder: number;
|
|
// AMS mapping - which AMS is connected to which nozzle
|
|
// Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS
|
|
ams_mapping: number[];
|
|
// Per-AMS extruder mapping - extracted from each AMS unit's info field
|
|
// Format: {ams_id: extruder_id} where extruder 0=right, 1=left
|
|
// Note: JSON keys are always strings
|
|
ams_extruder_map: Record<string, number>;
|
|
// Filament Track Switch accessory — null when not installed. When present,
|
|
// AMS slots aren't tied to a specific extruder; the FTS routes any slot to
|
|
// either extruder, so per-extruder slot filtering must be skipped.
|
|
fila_switch: FilaSwitchState | null;
|
|
// Per-AMS FTS inlet binding, {ams_id: 'A' | 'B'}, as set on the printer's
|
|
// "Manual AMS Setup" screen. Empty unless a switch is installed. An AMS with
|
|
// an entry here reaches BOTH nozzles through the switch, which is why it has
|
|
// no ams_extruder_map entry and must not be badged left or right.
|
|
ams_switch_inlet: Record<string, FtsInlet>;
|
|
// Which AMS slot each hotend is fed from, keyed by extruder id as a string.
|
|
// Empty on printers that don't report it (everything but the H2/X2 series).
|
|
extruder_slots: Record<string, ExtruderSlot>;
|
|
// Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
|
|
tray_now: number;
|
|
// Runout / filament-replacement guidance (#2587). Populated only while PAUSED.
|
|
// Global tray IDs (ams_id*4+slot, 128-135 = AMS-HT, 254 = external), matching
|
|
// the same numbering as tray_now so the AMS graphic can highlight them.
|
|
// expected_tray = the slot the firmware now expects filament in (null = idle,
|
|
// not paused, or unresolvable → "check the printer").
|
|
// previous_tray = the slot that ran out (null = unknown).
|
|
expected_tray: number | null;
|
|
previous_tray: number | null;
|
|
// AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
|
|
ams_status_main: number;
|
|
// AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
|
|
ams_status_sub: number;
|
|
// mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
|
|
mc_print_sub_stage: number;
|
|
// Timestamp of last AMS data update (for RFID refresh detection)
|
|
last_ams_update: number;
|
|
// Number of printable objects in current print (for skip objects feature)
|
|
printable_objects_count: number;
|
|
// Fan speeds (0-100 percentage, null if not available for this model)
|
|
cooling_fan_speed: number | null; // Part cooling fan
|
|
big_fan1_speed: number | null; // Auxiliary fan
|
|
big_fan2_speed: number | null; // Chamber/exhaust fan
|
|
heatbreak_fan_speed: number | null; // Hotend heatbreak fan
|
|
// Left auxiliary part cooling fan (optional P2S/X2D accessory, M106 P10).
|
|
// null = not installed / not reported by this model.
|
|
left_aux_fan_speed: number | null;
|
|
// Chamber exhaust fan present (P2S/X2D External Exhaust Fan kit, airduct part 3).
|
|
exhaust_fan_present: boolean;
|
|
firmware_version: string | null; // Firmware version from MQTT
|
|
// Developer LAN mode: true = enabled, false = disabled, null = unknown
|
|
developer_mode: boolean | null;
|
|
// AMS Filament Backup ("auto-switch" to a backup spool when one runs out).
|
|
// true = ON, false = OFF, null = unknown / unsupported (A1 family).
|
|
ams_filament_backup: boolean | null;
|
|
// Queue: printer is awaiting user ack that the build plate was cleared after a
|
|
// finished/failed print. Persisted across restarts (#961).
|
|
awaiting_plate_clear: boolean;
|
|
// AMS drying support
|
|
supports_drying: boolean;
|
|
// The AMS can dry, but only from the printer's own screen (P1 series, #2533).
|
|
drying_screen_only?: boolean;
|
|
// Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
|
|
supports_chamber_heater?: boolean;
|
|
}
|
|
|
|
export interface PrinterCreate {
|
|
name: string;
|
|
serial_number: string;
|
|
ip_address: string;
|
|
access_code: string;
|
|
model?: string;
|
|
location?: string;
|
|
auto_archive?: boolean;
|
|
// Maintenance Mode flag (#1476). Backend already gates MQTT, queue dispatch,
|
|
// scheduler, metrics and the print picker on this; toggling via PATCH
|
|
// /printers/{id} disconnects or reconnects MQTT accordingly.
|
|
is_active?: boolean;
|
|
external_camera_url?: string | null;
|
|
external_camera_type?: string | null;
|
|
external_camera_enabled?: boolean;
|
|
external_camera_snapshot_url?: string | null;
|
|
camera_rotation?: number;
|
|
plate_detection_enabled?: boolean;
|
|
plate_detection_roi?: PlateDetectionROI;
|
|
}
|
|
|
|
// Plate Detection
|
|
export interface PlateDetectionROI {
|
|
x: number; // X start % (0.0-1.0)
|
|
y: number; // Y start % (0.0-1.0)
|
|
w: number; // Width % (0.0-1.0)
|
|
h: number; // Height % (0.0-1.0)
|
|
}
|
|
|
|
export interface PlateDetectionResult {
|
|
is_empty: boolean;
|
|
confidence: number;
|
|
difference_percent: number;
|
|
message: string;
|
|
has_debug_image: boolean;
|
|
debug_image_url?: string;
|
|
needs_calibration: boolean;
|
|
light_warning?: boolean;
|
|
reference_count?: number;
|
|
max_references?: number;
|
|
roi?: PlateDetectionROI;
|
|
}
|
|
|
|
export interface PlateDetectionStatus {
|
|
available: boolean;
|
|
calibrated: boolean;
|
|
reference_count: number;
|
|
max_references: number;
|
|
message: string;
|
|
}
|
|
|
|
export interface CalibrationResult {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export interface PlateReference {
|
|
index: number;
|
|
label: string;
|
|
timestamp: string;
|
|
has_image: boolean;
|
|
thumbnail_url: string;
|
|
}
|
|
|
|
// Archive types
|
|
export interface ArchiveDuplicate {
|
|
id: number;
|
|
print_name: string | null;
|
|
created_at: string;
|
|
match_type: 'exact' | 'similar'; // 'exact' = hash match, 'similar' = name match
|
|
}
|
|
|
|
export interface ArchivePrinterMediaFile {
|
|
name: string;
|
|
path: string;
|
|
size: number;
|
|
mtime: string | null;
|
|
kind: 'timelapse' | 'ipcam';
|
|
}
|
|
|
|
export interface ArchivePrinterMedia {
|
|
archive_id: number;
|
|
printer_id: number | null;
|
|
local_timelapse: { name: string; size: number } | null;
|
|
remote_files: ArchivePrinterMediaFile[];
|
|
warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>;
|
|
}
|
|
|
|
export interface Archive {
|
|
id: number;
|
|
printer_id: number | null;
|
|
project_id: number | null;
|
|
project_name: string | null;
|
|
filename: string;
|
|
file_path: string;
|
|
file_size: number;
|
|
content_hash: string | null;
|
|
thumbnail_path: string | null;
|
|
timelapse_path: string | null;
|
|
source_3mf_path: string | null;
|
|
f3d_path: string | null;
|
|
duplicates: ArchiveDuplicate[] | null;
|
|
duplicate_count: number;
|
|
duplicate_sequence: number; // 0 = original, 1+ = nth duplicate
|
|
original_archive_id: number | null; // ID of the first/original archive
|
|
object_count: number | null;
|
|
print_name: string | null;
|
|
plate_id: number | null; // Selected plate of a multi-plate 3MF (#2603)
|
|
print_time_seconds: number | null;
|
|
actual_time_seconds: number | null; // Computed from started_at/completed_at
|
|
time_accuracy: number | null; // Percentage: 100 = perfect, >100 = faster than estimated
|
|
filament_used_grams: number | null;
|
|
filament_type: string | null;
|
|
filament_color: string | null;
|
|
layer_height: number | null;
|
|
total_layers: number | null;
|
|
nozzle_diameter: number | null;
|
|
bed_temperature: number | null;
|
|
bed_type: string | null; // Build plate type from 3MF (e.g. "Cool Plate", "Textured PEI Plate")
|
|
nozzle_temperature: number | null;
|
|
sliced_for_model: string | null; // Printer model this file was sliced for
|
|
status: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
extra_data: Record<string, unknown> | null;
|
|
makerworld_url: string | null;
|
|
designer: string | null;
|
|
external_url: string | null;
|
|
is_favorite: boolean;
|
|
tags: string | null;
|
|
notes: string | null;
|
|
cost: number | null;
|
|
photos: string[] | null;
|
|
failure_reason: string | null;
|
|
quantity: number;
|
|
energy_kwh: number | null;
|
|
energy_cost: number | null;
|
|
created_at: string;
|
|
// User tracking (Issue #206)
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
// Per-archive run aggregates from PrintLogEntry (#1378)
|
|
run_count: number;
|
|
last_run_at: string | null;
|
|
total_filament_actual_grams: number | null;
|
|
successful_run_count: number;
|
|
failed_run_count: number;
|
|
}
|
|
|
|
export interface ArchiveSlim {
|
|
printer_id: number | null;
|
|
print_name: string | null;
|
|
print_time_seconds: number | null;
|
|
actual_time_seconds: number | null;
|
|
filament_used_grams: number | null;
|
|
filament_type: string | null;
|
|
filament_color: string | null;
|
|
status: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
cost: number | null;
|
|
energy_kwh: number | null;
|
|
energy_cost: number | null;
|
|
quantity: number;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface PrintLogEntry {
|
|
id: number;
|
|
archive_id: number | null;
|
|
print_name: string | null;
|
|
printer_name: string | null;
|
|
printer_id: number | null;
|
|
status: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
duration_seconds: number | null;
|
|
filament_type: string | null;
|
|
filament_color: string | null;
|
|
filament_used_grams: number | null;
|
|
cost: number | null;
|
|
energy_kwh: number | null;
|
|
energy_cost: number | null;
|
|
failure_reason: string | null;
|
|
thumbnail_path: string | null;
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface PrintLogResponse {
|
|
items: PrintLogEntry[];
|
|
total: number;
|
|
}
|
|
|
|
export interface ArchiveStats {
|
|
total_prints: number;
|
|
successful_prints: number;
|
|
failed_prints: number;
|
|
cancelled_prints: number;
|
|
total_print_time_hours: number;
|
|
total_filament_grams: number;
|
|
total_cost: number;
|
|
prints_by_filament_type: Record<string, number>;
|
|
prints_by_printer: Record<string, number>;
|
|
// Name each printer id was last recorded under in the print log, so history
|
|
// belonging to a deleted printer keeps its label.
|
|
printer_names?: Record<string, string>;
|
|
average_time_accuracy: number | null;
|
|
time_accuracy_by_printer: Record<string, number> | null;
|
|
total_energy_kwh: number;
|
|
total_energy_cost: number;
|
|
// True when a date-filtered total-consumption query is running on incomplete
|
|
// snapshot history (e.g. right after upgrade, before hourly snapshots have
|
|
// a baseline). UI should explain why the number may undercount.
|
|
energy_data_warming_up?: boolean;
|
|
}
|
|
|
|
export interface TagInfo {
|
|
name: string;
|
|
count: number;
|
|
}
|
|
|
|
export interface FailureAnalysis {
|
|
period_days: number;
|
|
total_prints: number;
|
|
failed_prints: number;
|
|
failure_rate: number;
|
|
failures_by_reason: Record<string, number>;
|
|
failures_by_filament: Record<string, number>;
|
|
failures_by_printer: Record<string, number>;
|
|
failures_by_hour: Record<number, number>;
|
|
recent_failures: Array<{
|
|
id: number;
|
|
print_name: string;
|
|
failure_reason: string | null;
|
|
filament_type: string | null;
|
|
printer_id: number | null;
|
|
created_at: string | null;
|
|
}>;
|
|
trend: Array<{
|
|
week_start: string;
|
|
total_prints: number;
|
|
failed_prints: number;
|
|
failure_rate: number;
|
|
}>;
|
|
}
|
|
|
|
export interface BulkUploadResult {
|
|
uploaded: number;
|
|
failed: number;
|
|
results: Array<{ filename: string; id: number; status: string }>;
|
|
errors: Array<{ filename: string; error: string }>;
|
|
}
|
|
|
|
// Archive Comparison types
|
|
export interface ComparisonArchiveInfo {
|
|
id: number;
|
|
print_name: string;
|
|
status: string;
|
|
created_at: string | null;
|
|
printer_id: number | null;
|
|
project_name: string | null;
|
|
}
|
|
|
|
export interface ComparisonField {
|
|
field: string;
|
|
label: string;
|
|
unit: string | null;
|
|
values: (string | number | null)[];
|
|
raw_values: (string | number | null)[];
|
|
has_difference: boolean;
|
|
}
|
|
|
|
export interface SuccessCorrelationInsight {
|
|
field: string;
|
|
label: string;
|
|
insight: string;
|
|
success_avg?: number;
|
|
failed_avg?: number;
|
|
success_values?: string[];
|
|
failed_values?: string[];
|
|
}
|
|
|
|
export interface SuccessCorrelation {
|
|
has_both_outcomes: boolean;
|
|
message?: string;
|
|
successful_count?: number;
|
|
failed_count?: number;
|
|
insights?: SuccessCorrelationInsight[];
|
|
}
|
|
|
|
export interface ArchiveComparison {
|
|
archives: ComparisonArchiveInfo[];
|
|
comparison: ComparisonField[];
|
|
differences: ComparisonField[];
|
|
success_correlation: SuccessCorrelation;
|
|
}
|
|
|
|
export interface SimilarArchive {
|
|
archive: {
|
|
id: number;
|
|
print_name: string;
|
|
status: string;
|
|
created_at: string | null;
|
|
};
|
|
match_reason: string;
|
|
match_score: number;
|
|
}
|
|
|
|
// Project types
|
|
export interface ProjectStats {
|
|
total_archives: number;
|
|
total_items: number; // Sum of quantities (total items printed)
|
|
completed_prints: number; // Sum of quantities for completed prints (parts)
|
|
failed_prints: number;
|
|
queued_prints: number;
|
|
in_progress_prints: number;
|
|
total_print_time_hours: number;
|
|
total_filament_grams: number;
|
|
progress_percent: number | null; // Plates progress (total_archives / target_count)
|
|
parts_progress_percent: number | null; // Parts progress (completed_prints / target_parts_count)
|
|
estimated_cost: number;
|
|
total_energy_kwh: number;
|
|
total_energy_cost: number;
|
|
remaining_prints: number | null; // Remaining plates
|
|
remaining_parts: number | null; // Remaining parts
|
|
bom_total_items: number;
|
|
bom_completed_items: number;
|
|
bom_cost: number;
|
|
}
|
|
|
|
// A sub-project as listed on its parent's page. The figures cover the child's
|
|
// own subtree, so the listed rows add up to the parent's roll-up minus the
|
|
// parent's own prints (#1264).
|
|
export interface ProjectChildPreview {
|
|
id: number;
|
|
name: string;
|
|
color: string | null;
|
|
status: string;
|
|
progress_percent: number | null;
|
|
descendant_count: number;
|
|
total_archives: number;
|
|
completed_prints: number;
|
|
total_print_time_hours: number;
|
|
total_filament_grams: number;
|
|
total_cost: number; // Filament + energy + BOM, matching the parent's cost card
|
|
}
|
|
|
|
export interface Project {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
color: string | null;
|
|
status: string; // active, completed, archived
|
|
target_count: number | null; // Target number of plates/print jobs
|
|
target_parts_count: number | null; // Target number of parts/objects
|
|
target_sets: number | null; // Copies-per-file target (#1897)
|
|
notes: string | null;
|
|
attachments: ProjectAttachment[] | null;
|
|
tags: string | null;
|
|
due_date: string | null;
|
|
priority: string; // low, normal, high, urgent
|
|
budget: number | null;
|
|
is_template: boolean;
|
|
template_source_id: number | null;
|
|
parent_id: number | null;
|
|
parent_name: string | null;
|
|
children: ProjectChildPreview[];
|
|
descendant_count: number; // Sub-projects at any depth beneath this one (#1264)
|
|
created_at: string;
|
|
updated_at: string;
|
|
stats?: ProjectStats;
|
|
// This project's numbers combined with every sub-project's. Null when there
|
|
// are none, since it would only repeat `stats` (#1264).
|
|
rollup_stats?: ProjectStats | null;
|
|
url: string | null; // External link rendered next to project name on the card (#1155)
|
|
cover_image_filename: string | null; // Filename within project attachments dir (#1155)
|
|
}
|
|
|
|
export interface ProjectAttachment {
|
|
filename: string;
|
|
original_name: string;
|
|
size: number;
|
|
uploaded_at: string;
|
|
}
|
|
|
|
// Completed-run count for one library file inside a project (#1897)
|
|
export interface ProjectFileProgress {
|
|
file_id: number;
|
|
completed_count: number;
|
|
}
|
|
|
|
export interface ArchivePreview {
|
|
id: number;
|
|
print_name: string | null;
|
|
thumbnail_path: string | null;
|
|
status: string;
|
|
filament_type: string | null;
|
|
filament_color: string | null;
|
|
}
|
|
|
|
export interface ProjectListItem {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
color: string | null;
|
|
status: string;
|
|
target_count: number | null; // Target number of plates/print jobs
|
|
target_parts_count: number | null; // Target number of parts/objects
|
|
target_sets: number | null; // #1897 — the shared edit dialog seeds itself from this
|
|
budget: number | null;
|
|
tags: string | null; // #2536 — the shared edit dialog seeds itself from this
|
|
due_date: string | null; // #2536
|
|
priority: string; // #2536
|
|
created_at: string;
|
|
archive_count: number; // Number of print jobs (plates)
|
|
total_items: number; // Sum of quantities (total items printed, including failed)
|
|
completed_count: number; // Sum of quantities for completed prints only (parts)
|
|
failed_count: number; // Sum of quantities for failed prints
|
|
queue_count: number;
|
|
progress_percent: number | null; // Plates progress
|
|
parent_id: number | null; // #1264 — set when this is a sub-project
|
|
child_count: number; // #1264 — direct sub-projects only
|
|
archives: ArchivePreview[];
|
|
url: string | null; // #1155
|
|
cover_image_filename: string | null; // #1155
|
|
}
|
|
|
|
export interface ProjectCreate {
|
|
name: string;
|
|
description?: string;
|
|
color?: string;
|
|
target_count?: number;
|
|
target_parts_count?: number;
|
|
target_sets?: number;
|
|
notes?: string;
|
|
tags?: string;
|
|
due_date?: string;
|
|
priority?: string;
|
|
budget?: number | null;
|
|
parent_id?: number;
|
|
url?: string | null; // #1155
|
|
}
|
|
|
|
export interface ProjectUpdate {
|
|
name?: string;
|
|
description?: string;
|
|
color?: string;
|
|
status?: string;
|
|
target_count?: number;
|
|
target_parts_count?: number;
|
|
target_sets?: number | null; // #1897 — explicit null clears the copies-per-file target
|
|
notes?: string;
|
|
tags?: string | null; // #2536 — explicit null clears the tags
|
|
due_date?: string | null; // #2536 — explicit null clears the due date
|
|
priority?: string;
|
|
budget?: number | null;
|
|
parent_id?: number;
|
|
url?: string | null; // #1155 — explicit null clears the URL
|
|
}
|
|
|
|
// BOM Types - Tracks sourced/purchased parts (hardware, electronics, etc.)
|
|
export interface BOMItem {
|
|
id: number;
|
|
project_id: number;
|
|
name: string;
|
|
quantity_needed: number;
|
|
quantity_acquired: number;
|
|
unit_price: number | null;
|
|
sourcing_url: string | null;
|
|
archive_id: number | null;
|
|
archive_name: string | null;
|
|
stl_filename: string | null;
|
|
remarks: string | null;
|
|
sort_order: number;
|
|
is_complete: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface BOMItemCreate {
|
|
name: string;
|
|
quantity_needed?: number;
|
|
unit_price?: number;
|
|
sourcing_url?: string;
|
|
archive_id?: number;
|
|
stl_filename?: string;
|
|
remarks?: string;
|
|
}
|
|
|
|
export interface BOMItemUpdate {
|
|
name?: string;
|
|
quantity_needed?: number;
|
|
quantity_acquired?: number;
|
|
unit_price?: number;
|
|
sourcing_url?: string;
|
|
archive_id?: number;
|
|
stl_filename?: string;
|
|
remarks?: string;
|
|
}
|
|
|
|
// Project Export/Import Types
|
|
export interface BOMItemExport {
|
|
name: string;
|
|
quantity_needed: number;
|
|
quantity_acquired: number;
|
|
unit_price: number | null;
|
|
sourcing_url: string | null;
|
|
stl_filename: string | null;
|
|
remarks: string | null;
|
|
}
|
|
|
|
export interface LinkedFolderExport {
|
|
name: string;
|
|
}
|
|
|
|
export interface ProjectExport {
|
|
name: string;
|
|
description: string | null;
|
|
color: string | null;
|
|
status: string;
|
|
target_count: number | null;
|
|
target_parts_count: number | null;
|
|
target_sets: number | null; // #1897
|
|
notes: string | null;
|
|
tags: string | null;
|
|
due_date: string | null;
|
|
priority: string;
|
|
budget: number | null;
|
|
bom_items: BOMItemExport[];
|
|
linked_folders: LinkedFolderExport[];
|
|
}
|
|
|
|
export interface ProjectImport {
|
|
name: string;
|
|
description?: string;
|
|
color?: string;
|
|
status?: string;
|
|
target_count?: number;
|
|
target_parts_count?: number;
|
|
target_sets?: number; // #1897
|
|
notes?: string;
|
|
tags?: string;
|
|
due_date?: string;
|
|
priority?: string;
|
|
budget?: number | null;
|
|
bom_items?: BOMItemExport[];
|
|
linked_folders?: LinkedFolderExport[];
|
|
}
|
|
|
|
// Timeline Types
|
|
export interface TimelineEvent {
|
|
event_type: string;
|
|
timestamp: string;
|
|
title: string;
|
|
description: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
}
|
|
|
|
// API Key types
|
|
export interface APIKey {
|
|
id: number;
|
|
name: string;
|
|
key_prefix: string;
|
|
user_id: number | null; // Owner; null on legacy keys created before per-user ownership (#1182)
|
|
can_queue: boolean;
|
|
can_control_printer: boolean;
|
|
can_read_status: boolean;
|
|
can_manage_library: boolean;
|
|
can_manage_inventory: boolean;
|
|
can_manage_maintenance: boolean;
|
|
can_manage_archives: boolean;
|
|
can_manage_projects: boolean;
|
|
can_access_cloud: boolean;
|
|
can_update_energy_cost: boolean;
|
|
printer_ids: number[] | null;
|
|
enabled: boolean;
|
|
last_used: string | null;
|
|
created_at: string;
|
|
expires_at: string | null;
|
|
}
|
|
|
|
export interface APIKeyCreate {
|
|
name: string;
|
|
can_queue?: boolean;
|
|
can_control_printer?: boolean;
|
|
can_read_status?: boolean;
|
|
can_manage_library?: boolean;
|
|
can_manage_inventory?: boolean;
|
|
can_manage_maintenance?: boolean;
|
|
can_manage_archives?: boolean;
|
|
can_manage_projects?: boolean;
|
|
can_access_cloud?: boolean;
|
|
can_update_energy_cost?: boolean;
|
|
printer_ids?: number[] | null;
|
|
expires_at?: string | null;
|
|
}
|
|
|
|
export interface APIKeyCreateResponse extends APIKey {
|
|
key: string; // Full key, only shown on creation
|
|
}
|
|
|
|
export interface APIKeyUpdate {
|
|
name?: string;
|
|
can_queue?: boolean;
|
|
can_control_printer?: boolean;
|
|
can_read_status?: boolean;
|
|
can_manage_library?: boolean;
|
|
can_manage_inventory?: boolean;
|
|
can_manage_maintenance?: boolean;
|
|
can_manage_archives?: boolean;
|
|
can_manage_projects?: boolean;
|
|
can_access_cloud?: boolean;
|
|
can_update_energy_cost?: boolean;
|
|
printer_ids?: number[] | null;
|
|
enabled?: boolean;
|
|
expires_at?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Tri-state calibration option (BambuStudio parity): "off" never runs it,
|
|
* "on" forces it every print, "auto" lets the printer skip it if it was done
|
|
* recently. Used by bed_levelling, flow_cali, and nozzle_offset_cali.
|
|
*/
|
|
export type CalibrationMode = 'off' | 'on' | 'auto';
|
|
|
|
// Settings types
|
|
export interface AppSettings {
|
|
auto_archive: boolean;
|
|
save_thumbnails: boolean;
|
|
capture_finish_photo: boolean;
|
|
finish_photo_restore_plate: boolean;
|
|
default_filament_cost: number;
|
|
currency: string;
|
|
energy_cost_per_kwh: number;
|
|
energy_tracking_mode: 'print' | 'total';
|
|
check_updates: boolean;
|
|
check_printer_firmware: boolean;
|
|
include_beta_updates: boolean;
|
|
// #1589: false hides the local username/password form on the login page;
|
|
// BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
|
|
// true so the env-var recovery path is visible to the SPA.
|
|
local_login_enabled: boolean;
|
|
language: string;
|
|
notification_language: string;
|
|
// AMS threshold settings
|
|
ams_humidity_good: number; // <= this is green
|
|
ams_humidity_fair: number; // <= this is orange, > is red
|
|
ams_temp_good: number; // <= this is green/blue
|
|
ams_temp_fair: number; // <= this is orange, > is red
|
|
// Separate from the display band (#2905). null = unset, which the backend
|
|
// resolves to ams_temp_fair so existing installs are unaffected.
|
|
ams_temp_alarm: number | null;
|
|
ams_history_retention_days: number; // days to keep AMS sensor history
|
|
// Queue auto-drying settings
|
|
queue_drying_enabled: boolean; // Auto-dry AMS between queued prints
|
|
queue_drying_block: boolean; // Block queue until drying completes
|
|
ambient_drying_enabled: boolean; // Auto-dry idle printers based on humidity regardless of queue
|
|
print_drying_enabled: boolean; // Continue drying while a print is running on capable hardware
|
|
drying_presets: string; // JSON blob of drying presets per filament type
|
|
ams_humidity_thresholds: string; // JSON blob of per-filament humidity thresholds (#1605)
|
|
gcode_snippets: string; // JSON: per-model G-code injection snippets
|
|
// Scheduled local backup
|
|
local_backup_enabled: boolean;
|
|
local_backup_schedule: string;
|
|
local_backup_time: string;
|
|
local_backup_retention: number;
|
|
local_backup_path: string;
|
|
// Print modal settings
|
|
per_printer_mapping_expanded: boolean; // Whether custom mapping is expanded by default in print modal
|
|
// Date/time format settings
|
|
date_format: 'system' | 'us' | 'eu' | 'iso';
|
|
time_format: 'system' | '12h' | '24h';
|
|
// Filament tracking
|
|
disable_filament_warnings: boolean; // Disable filament warnings (print insufficiency and assignment mismatch)
|
|
prefer_lowest_filament: boolean; // When multiple spools match, prefer lowest remaining filament
|
|
spoolman_enabled: boolean; // True when the user has switched filament tracking to Spoolman; backend includes this in the /settings/ response even though earlier consumers read it from the dedicated /settings/spoolman endpoint as a string
|
|
auto_add_unknown_rfid: boolean; // When false, the backend skips auto-creating inventory spools for unknown RFID tags and instead broadcasts an unknown_tag event for the confirmation modal
|
|
spoolman_url: string;
|
|
// Default printer
|
|
default_printer_id: number | null;
|
|
pipeline_max_copies: number;
|
|
// Dark mode theme settings
|
|
dark_style: 'classic' | 'glow' | 'vibrant';
|
|
dark_background: 'neutral' | 'warm' | 'cool' | 'oled' | 'slate' | 'forest';
|
|
dark_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
|
|
// Light mode theme settings
|
|
light_style: 'classic' | 'glow' | 'vibrant';
|
|
light_background: 'neutral' | 'warm' | 'cool';
|
|
light_accent: 'green' | 'teal' | 'blue' | 'orange' | 'purple' | 'red';
|
|
// FTP retry settings
|
|
ftp_retry_enabled: boolean;
|
|
ftp_retry_count: number;
|
|
ftp_retry_delay: number;
|
|
ftp_timeout: number;
|
|
// MQTT relay settings
|
|
mqtt_enabled: boolean;
|
|
mqtt_broker: string;
|
|
mqtt_port: number;
|
|
mqtt_username: string;
|
|
mqtt_password: string;
|
|
mqtt_topic_prefix: string;
|
|
mqtt_use_tls: boolean;
|
|
// External URL for notifications
|
|
external_url: string;
|
|
// Directory holding docker-compose.yml, shown in the update instructions (#2664)
|
|
docker_compose_dir: string;
|
|
// Home Assistant integration
|
|
ha_enabled: boolean;
|
|
ha_url: string;
|
|
ha_token: string;
|
|
ha_url_from_env: boolean;
|
|
ha_token_from_env: boolean;
|
|
ha_env_managed: boolean;
|
|
// File Manager / Library settings
|
|
library_archive_mode: 'always' | 'never' | 'ask';
|
|
library_disk_warning_gb: number;
|
|
// Camera view settings
|
|
camera_view_mode: 'window' | 'embedded';
|
|
// Preferred slicer (server-side API / sidecar)
|
|
preferred_slicer: 'bambu_studio' | 'orcaslicer';
|
|
// Desktop "Open in Slicer" override (#1329). Null inherits from
|
|
// preferred_slicer so existing installs behave identically.
|
|
open_in_slicer: 'bambu_studio' | 'orcaslicer' | null;
|
|
// Where slicing runs, independent of which slicer binary the sidecar drives.
|
|
// Only 'sidecar' is implemented today; see lib/sliceEngines.ts.
|
|
slice_engine: 'sidecar' | 'browser';
|
|
// Use the slicer-API sidecar for slicing (in-app modal) vs desktop URI scheme
|
|
use_slicer_api: boolean;
|
|
// Per-install sidecar URLs. Empty string falls back to the env defaults.
|
|
orcaslicer_api_url: string;
|
|
bambu_studio_api_url: string;
|
|
// Minutes of silence from the sidecar before a slice is abandoned. Bounds
|
|
// stalls, not total slicing time — a model that keeps reporting progress
|
|
// runs to completion however long it takes.
|
|
slicer_stall_timeout_minutes: number;
|
|
// Prometheus metrics
|
|
prometheus_enabled: boolean;
|
|
prometheus_token: string;
|
|
// Bed cooled threshold
|
|
bed_cooled_threshold: number;
|
|
// Inventory low stock threshold
|
|
low_stock_threshold: number;
|
|
// Session policy (#1706) — admin-set ceiling, hours, [1, 720]
|
|
session_max_hours: number;
|
|
// User email notifications toggle
|
|
user_notifications_enabled: boolean;
|
|
// Default print options
|
|
default_bed_levelling: CalibrationMode;
|
|
default_flow_cali: CalibrationMode;
|
|
default_vibration_cali: boolean;
|
|
default_layer_inspect: boolean;
|
|
default_timelapse: boolean;
|
|
default_nozzle_offset_cali: CalibrationMode;
|
|
// Staggered batch start defaults
|
|
stagger_group_size: number;
|
|
stagger_interval_minutes: number;
|
|
// Finance budget reset window
|
|
billing_enabled: boolean;
|
|
printer_kill_switch_enabled: boolean;
|
|
finance_budget_reset_day: number;
|
|
finance_budget_reset_timezone: string;
|
|
// Plate-clear confirmation
|
|
require_plate_clear: boolean;
|
|
// Shortest job first scheduling
|
|
queue_shortest_first: boolean;
|
|
// How many printers the queue may upload to at once (#2555). 1 restores the
|
|
// old strictly-serial behaviour, where every printer waited out every other
|
|
// printer's transfer.
|
|
queue_max_concurrent_uploads: number;
|
|
// Preheat / heat-soak before queued prints (#1468). Master toggle is the
|
|
// default for new queue items; per-item PrintQueueItem.preheat_override can
|
|
// flip the decision per print. Chamber target derives from the loaded AMS
|
|
// filament types via preheat_filament_targets (JSON map of type → °C, max
|
|
// across loaded slots); the per-item override field bypasses derivation.
|
|
preheat_enabled: boolean;
|
|
preheat_filament_targets: string;
|
|
preheat_max_wait_seconds: number;
|
|
preheat_soak_seconds: number;
|
|
queue_keep_bed_warm: boolean;
|
|
queue_keep_warm_bed_temp: number;
|
|
queue_keep_warm_max_minutes: number;
|
|
// User-configurable presets for the printer-card popovers (JSON arrays of 3 ints).
|
|
// Empty string = use built-in defaults.
|
|
nozzle_temp_presets: string;
|
|
bed_temp_presets: string;
|
|
chamber_temp_presets: string;
|
|
fan_speed_presets: string;
|
|
// Default sidebar order (admin-set for all users)
|
|
default_sidebar_order: string;
|
|
// LDAP authentication
|
|
ldap_enabled: boolean;
|
|
ldap_server_url: string;
|
|
ldap_bind_dn: string;
|
|
ldap_bind_password: string;
|
|
ldap_search_base: string;
|
|
ldap_user_filter: string;
|
|
ldap_security: string;
|
|
ldap_group_mapping: string;
|
|
ldap_auto_provision: boolean;
|
|
ldap_default_group: string;
|
|
obico_enabled: boolean;
|
|
obico_ml_url: string;
|
|
obico_ml_token: string;
|
|
obico_sensitivity: 'low' | 'medium' | 'high';
|
|
obico_action: 'notify' | 'pause' | 'pause_and_off';
|
|
obico_poll_interval: number;
|
|
obico_enabled_printers: string;
|
|
// Inventory forecasting global lead time
|
|
forecast_global_lead_time_days: number;
|
|
location_sensor_poll_interval: number;
|
|
// JSON map of sensor category → {alertAbove, alertBelow, notifyOnAlert},
|
|
// seeding new storage-location sensor bindings. Empty = built-in defaults.
|
|
// Server-backed so two admins seed the same alert rules and a restore
|
|
// brings them back; see utils/locationSensorDefaults.ts.
|
|
location_sensor_alert_defaults: string;
|
|
}
|
|
|
|
export type AppSettingsUpdate = Partial<AppSettings>;
|
|
|
|
// MQTT relay status
|
|
export interface MQTTStatus {
|
|
enabled: boolean;
|
|
connected: boolean;
|
|
broker: string;
|
|
port: number;
|
|
topic_prefix: string;
|
|
}
|
|
|
|
// Cloud types
|
|
export interface CloudAuthStatus {
|
|
is_authenticated: boolean;
|
|
email: string | null;
|
|
region?: 'global' | 'china' | null;
|
|
/** A token is stored but Bambu no longer accepts it — tell the user why the login form is back. */
|
|
sign_in_expired?: boolean;
|
|
}
|
|
|
|
export interface CloudLoginResponse {
|
|
success: boolean;
|
|
needs_verification: boolean;
|
|
message: string;
|
|
verification_type?: 'email' | 'totp' | null;
|
|
tfa_key?: string | null;
|
|
/**
|
|
* Machine-readable cause of a failure. 'captcha' means Bambu's anti-abuse
|
|
* layer is challenging this network and no credential will be accepted until
|
|
* it clears — the UI must explain that in place rather than toast `message`.
|
|
*/
|
|
reason?: 'captcha' | string | null;
|
|
}
|
|
|
|
// Orca Cloud types — paste-flow PKCE handshake against auth.orcaslicer.com.
|
|
// See backend/app/services/orca_cloud.py for the deep dive on why this
|
|
// flow is paste-based rather than callback-based.
|
|
export interface OrcaDeviceStartResponse {
|
|
user_code: string;
|
|
verification_uri: string;
|
|
verification_uri_complete: string;
|
|
interval: number;
|
|
expires_in: number;
|
|
}
|
|
|
|
export type OrcaDevicePollStatus =
|
|
| 'authorization_pending'
|
|
| 'slow_down'
|
|
| 'access_denied'
|
|
| 'expired_token'
|
|
| 'complete';
|
|
|
|
export interface OrcaDevicePollResponse {
|
|
status: OrcaDevicePollStatus;
|
|
connected: boolean;
|
|
email: string | null;
|
|
user_id: string | null;
|
|
}
|
|
|
|
export interface OrcaAuthStatusResponse {
|
|
connected: boolean;
|
|
email: string | null;
|
|
user_id: string | null;
|
|
}
|
|
|
|
// Orca profiles are shaped to match Bambu Cloud's SlicerSetting on the wire
|
|
// so the frontend can use the same visual components for both surfaces (cards,
|
|
// grouped sections, filter bar). Backend handles the source-specific
|
|
// transformation in routes/orca_cloud.py::_orca_to_setting.
|
|
export interface OrcaProfileMeta {
|
|
setting_id: string;
|
|
name: string;
|
|
type: string;
|
|
version: string | null;
|
|
user_id: string | null;
|
|
updated_time: string | null;
|
|
is_custom: boolean;
|
|
}
|
|
|
|
export interface OrcaProfileListResponse {
|
|
filament: OrcaProfileMeta[];
|
|
printer: OrcaProfileMeta[];
|
|
process: OrcaProfileMeta[];
|
|
}
|
|
|
|
export interface OrcaProfileDetail {
|
|
setting_id: string;
|
|
name: string;
|
|
type: string;
|
|
version: string | null;
|
|
base_id: string | null;
|
|
update_time: string | null;
|
|
setting: Record<string, unknown>;
|
|
}
|
|
|
|
// MakerWorld integration. Full metadata/instance shapes come back as
|
|
// Record<string, unknown> — MakerWorld's API adds fields over time, so we
|
|
// pass them through verbatim rather than maintaining a brittle mirror.
|
|
export interface MakerworldStatus {
|
|
has_cloud_token: boolean;
|
|
can_download: boolean;
|
|
/** A token is stored but Bambu rejected it — downloads will fail until the user signs in again. */
|
|
sign_in_expired?: boolean;
|
|
}
|
|
|
|
export interface MakerworldResolvedModel {
|
|
model_id: number;
|
|
profile_id: number | null;
|
|
design: Record<string, unknown>;
|
|
instances: Array<Record<string, unknown>>;
|
|
already_imported_library_ids: number[];
|
|
}
|
|
|
|
export interface MakerworldImportResponse {
|
|
library_file_id: number;
|
|
filename: string;
|
|
folder_id: number | null;
|
|
profile_id: number | null;
|
|
was_existing: boolean;
|
|
}
|
|
|
|
export interface MakerworldRecentImport {
|
|
library_file_id: number;
|
|
filename: string;
|
|
folder_id: number | null;
|
|
thumbnail_path: string | null;
|
|
source_url: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface SlicerSetting {
|
|
setting_id: string;
|
|
name: string;
|
|
type: string;
|
|
version: string | null;
|
|
user_id: string | null;
|
|
updated_time: string | null;
|
|
is_custom: boolean;
|
|
}
|
|
|
|
export interface SpoolCatalogEntry {
|
|
id: number;
|
|
name: string;
|
|
weight: number;
|
|
is_default: boolean;
|
|
}
|
|
|
|
export interface StorageLocation {
|
|
id: number;
|
|
name: string;
|
|
identifier: string | null;
|
|
spool_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ColorCatalogEntry {
|
|
id: number;
|
|
manufacturer: string;
|
|
color_name: string;
|
|
hex_color: string;
|
|
material: string | null;
|
|
is_default: boolean;
|
|
// #1154: optional multi-colour gradient stops + visual effect.
|
|
extra_colors?: string | null;
|
|
effect_type?: string | null;
|
|
}
|
|
|
|
export interface ColorLookupResult {
|
|
found: boolean;
|
|
hex_color: string | null;
|
|
material: string | null;
|
|
}
|
|
|
|
export interface SlicerSettingsResponse {
|
|
filament: SlicerSetting[];
|
|
printer: SlicerSetting[];
|
|
process: SlicerSetting[];
|
|
}
|
|
|
|
export interface SlicerSettingDetail {
|
|
message?: string | null;
|
|
code?: string | null;
|
|
error?: string | null;
|
|
public: boolean;
|
|
version?: string | null;
|
|
type: string;
|
|
name: string;
|
|
update_time?: string | null;
|
|
nickname?: string | null;
|
|
base_id?: string | null;
|
|
setting: Record<string, unknown>;
|
|
filament_id?: string | null;
|
|
setting_id?: string | null;
|
|
}
|
|
|
|
export interface SlicerSettingCreate {
|
|
type: string; // 'filament', 'print', or 'printer'
|
|
name: string;
|
|
base_id: string;
|
|
setting: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SlicerSettingUpdate {
|
|
name?: string;
|
|
setting?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface SlicerSettingDeleteResponse {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
// Built-in filament fallback (static table from backend)
|
|
export interface BuiltinFilament {
|
|
filament_id: string;
|
|
name: string;
|
|
}
|
|
|
|
// Slice request/response — POST /library/files/{id}/slice and /archives/{id}/slice
|
|
//
|
|
// Two preset shapes are accepted per slot:
|
|
// - Legacy bare integer ids (`*_preset_id`) — pre-cloud-tier clients.
|
|
// - Source-aware refs (`*_preset: PresetRef`) — new SliceModal that picks
|
|
// across cloud / local / standard tiers. Source-aware refs win when both
|
|
// are present in the same payload.
|
|
export type PresetSource = 'orca_cloud' | 'cloud' | 'local' | 'standard';
|
|
export interface PresetRef {
|
|
source: PresetSource;
|
|
id: string;
|
|
}
|
|
/**
|
|
* Why a preset's effective values are unavailable.
|
|
*
|
|
* `sidecar_outdated` is the one that matters in practice: an install pulls its
|
|
* sidecar as `SIDECAR_TAG:-latest` regardless of which Bambuddy channel it is
|
|
* on, so a user can perfectly well be running a current Bambuddy against a
|
|
* sidecar that predates this endpoint. That has a one-line fix, and saying so
|
|
* beats a generic "could not read the values".
|
|
*/
|
|
export type SlicerPresetValuesReason =
|
|
| 'ok'
|
|
| 'sidecar_outdated'
|
|
| 'sidecar_unavailable'
|
|
| 'not_configured'
|
|
| 'preset_unresolved';
|
|
|
|
export interface SlicerPresetValues {
|
|
/** False when the sidecar could not supply values; `values` is then empty. */
|
|
resolved: boolean;
|
|
/** Flattened key -> value map, in the string forms a process preset stores. */
|
|
values: Record<string, string | string[]>;
|
|
reason: SlicerPresetValuesReason;
|
|
}
|
|
|
|
export interface SliceRequest {
|
|
printer_preset_id?: number;
|
|
process_preset_id?: number;
|
|
filament_preset_id?: number;
|
|
printer_preset?: PresetRef;
|
|
process_preset?: PresetRef;
|
|
filament_preset?: PresetRef;
|
|
// Multi-color: one PresetRef per plate slot, in plate order. Always
|
|
// preferred over the singular `filament_preset` when both are sent; the
|
|
// backend validator promotes a singular into a one-element list when this
|
|
// is omitted, so legacy single-color clients keep working unchanged.
|
|
filament_presets?: PresetRef[];
|
|
// Per-slot filament colour as `#RRGGBB` / `#RRGGBBAA`, same plate order as
|
|
// `filament_presets` (#2977). Neither slicer stores a colour on a filament
|
|
// *preset* -- it is a per-project property -- so without this every sliced
|
|
// file records the CLI's built-in #00AE42 and the print dialog reports a
|
|
// colour mismatch against whatever is loaded in the AMS. An empty string in
|
|
// any position hands that slot back to the backend's fallback chain (the
|
|
// preset's own default_filament_colour, then the source plate's colour).
|
|
filament_colours?: string[];
|
|
plate?: number;
|
|
export_3mf?: boolean;
|
|
// Build-plate override (#1337). When omitted, the slicer uses the process
|
|
// preset's curr_bed_type as-is. Canonical values match BambuStudio /
|
|
// OrcaSlicer's enum: "Cool Plate", "Engineering Plate", "High Temp Plate",
|
|
// "Textured PEI Plate", "Smooth PEI Plate", "Cool Plate (SuperTack)",
|
|
// "Supertack Plate".
|
|
bed_type?: string | null;
|
|
// "Slice as designed" (#2611). 3MF only: slice using the file's embedded
|
|
// project_settings.config (the designer's own wall count, infill, etc.)
|
|
// instead of the picked profile triplet. The preset refs above are still
|
|
// required by the backend validator but go unused on this path.
|
|
use_embedded_settings?: boolean;
|
|
// Process settings the user edited in the slice modal's settings panel,
|
|
// already serialised into the string forms a process preset stores ("1" for
|
|
// a bool, "20%" for a percent, a list for the per-extruder vectors). Patched
|
|
// onto the resolved process JSON after the designer's carried tweaks, so an
|
|
// explicit choice here wins. Omitted when the panel is untouched.
|
|
process_overrides?: Record<string, string | string[]>;
|
|
// Design settings carried from the source 3MF (#2622) — a list of keys the
|
|
// file flags as changed from the system preset, not values.
|
|
design_overrides?: string[];
|
|
// Layout passes the slicer runs before slicing (#2548), both off by
|
|
// default because they move or rotate the objects the user laid out.
|
|
// Unlike the fields above these are CLI actions rather than profile
|
|
// values, so they apply on the embedded-settings path too.
|
|
auto_orient?: boolean;
|
|
auto_arrange?: boolean;
|
|
}
|
|
|
|
// GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
|
|
export type SlicerCloudStatus = 'ok' | 'not_authenticated' | 'expired' | 'unreachable';
|
|
export interface UnifiedPreset {
|
|
id: string;
|
|
name: string;
|
|
source: PresetSource;
|
|
// Populated for the filament slot only — used by the SliceModal multi-color
|
|
// pre-pick to score presets against each plate slot's required (type,
|
|
// colour). Optional because the bundled / standard tier rarely carries a
|
|
// colour (colour is a runtime spool attribute on Bambu) and older API
|
|
// responses pre-date these fields entirely.
|
|
filament_type?: string | null;
|
|
filament_colour?: string | null;
|
|
// Printer-preset names a process / filament preset declares itself
|
|
// compatible with. Populated for the local tier (the slicer's own
|
|
// `compatible_printers`); null for cloud / standard. The SliceModal filters
|
|
// the process / filament dropdowns by the selected printer using this when
|
|
// present (#1325).
|
|
compatible_printers?: string[] | null;
|
|
}
|
|
export interface UnifiedPresetsBySlot {
|
|
printer: UnifiedPreset[];
|
|
process: UnifiedPreset[];
|
|
filament: UnifiedPreset[];
|
|
}
|
|
export interface UnifiedPresetsResponse {
|
|
// Priority order: local > orca_cloud > cloud > standard. No cross-tier
|
|
// dedup — every tier surfaces its full list so the user can pick from
|
|
// any source. The order drives auto-pick + visual group rendering only.
|
|
orca_cloud: UnifiedPresetsBySlot;
|
|
cloud: UnifiedPresetsBySlot;
|
|
local: UnifiedPresetsBySlot;
|
|
standard: UnifiedPresetsBySlot;
|
|
cloud_status: SlicerCloudStatus;
|
|
orca_cloud_status: SlicerCloudStatus;
|
|
}
|
|
|
|
// Slicer Pipelines (#1425) — named bundles of preset slots the SliceModal
|
|
// can apply in one click. PR A surfaces only the bundle; target_* and
|
|
// fanout_strategy round-trip from the backend but the UI doesn't yet expose
|
|
// them (they come alive in PR B / PR C).
|
|
export interface SlicerPipeline {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
printer_preset: PresetRef;
|
|
process_preset: PresetRef;
|
|
filament_presets: PresetRef[];
|
|
bed_type: string | null;
|
|
target_kind: 'specific_printer' | 'printer_class';
|
|
target_printer_id: number | null;
|
|
target_model_class: string | null;
|
|
fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin';
|
|
created_by: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
export interface SlicerPipelineCreateRequest {
|
|
name: string;
|
|
description?: string | null;
|
|
printer_preset: PresetRef;
|
|
process_preset: PresetRef;
|
|
filament_presets: PresetRef[];
|
|
bed_type?: string | null;
|
|
}
|
|
export type SlicerPipelineUpdateRequest = Partial<SlicerPipelineCreateRequest> & {
|
|
target_kind?: 'specific_printer' | 'printer_class';
|
|
// ``target_printer_id: 0`` means "clear the target" — the backend maps that
|
|
// to null. Use null in TypeScript for the same intent.
|
|
target_printer_id?: number | null;
|
|
target_model_class?: string | null;
|
|
fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin';
|
|
};
|
|
export interface SlicerPipelinesListResponse {
|
|
pipelines: SlicerPipeline[];
|
|
}
|
|
|
|
// Slicer Pipeline runs (#1425 PR B + PR C)
|
|
export type PipelineEligibilityKind =
|
|
| 'printer_not_set'
|
|
| 'printer_not_found'
|
|
| 'printer_disabled'
|
|
| 'printer_offline'
|
|
| 'filament_type_mismatch'
|
|
| 'filament_color_mismatch'
|
|
| 'ams_slot_missing'
|
|
| 'filament_unverified'
|
|
| 'no_class_matches'
|
|
| 'class_not_set';
|
|
export interface PipelineEligibilityIssue {
|
|
kind: PipelineEligibilityKind;
|
|
slot_index: number | null;
|
|
expected: string | null;
|
|
actual: string | null;
|
|
}
|
|
export interface PipelinePerPrinterReport {
|
|
printer_id: number;
|
|
printer_name: string;
|
|
ok: boolean;
|
|
issues: PipelineEligibilityIssue[];
|
|
}
|
|
export interface PipelineEligibilityReport {
|
|
ok: boolean;
|
|
target_kind: 'specific_printer' | 'printer_class';
|
|
target_printer_id: number | null;
|
|
target_printer_name: string | null;
|
|
target_model_class: string | null;
|
|
issues: PipelineEligibilityIssue[];
|
|
printer_reports: PipelinePerPrinterReport[];
|
|
}
|
|
export interface PipelineJob {
|
|
id: number;
|
|
pipeline_run_id: number;
|
|
copy_index: number;
|
|
assigned_printer_id: number | null;
|
|
assigned_printer_name: string | null;
|
|
queue_entry_id: number | null;
|
|
status:
|
|
| 'pending'
|
|
| 'awaiting_printer'
|
|
| 'queued'
|
|
| 'printing'
|
|
| 'completed'
|
|
| 'failed'
|
|
| 'cancelled';
|
|
error_message: string | null;
|
|
dispatched_at: string | null;
|
|
completed_at: string | null;
|
|
}
|
|
export interface PipelineRun {
|
|
id: number;
|
|
pipeline_id: number | null;
|
|
pipeline_name: string | null;
|
|
source_library_file_id: number | null;
|
|
source_archive_id: number | null;
|
|
source_filename: string | null;
|
|
parent_run_id: number | null;
|
|
copies: number;
|
|
copies_completed: number;
|
|
copies_failed: number;
|
|
copies_cancelled: number;
|
|
copies_in_progress: number;
|
|
status:
|
|
| 'queued'
|
|
| 'slicing'
|
|
| 'dispatching'
|
|
| 'in_progress'
|
|
| 'completed'
|
|
| 'failed'
|
|
| 'partial_failure'
|
|
| 'cancelled';
|
|
slice_job_id: number | null;
|
|
sliced_library_file_id: number | null;
|
|
eligibility_overridden: boolean;
|
|
error_message: string | null;
|
|
created_by: number | null;
|
|
created_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
jobs: PipelineJob[];
|
|
target_kind: 'specific_printer' | 'printer_class' | null;
|
|
target_printer_id: number | null;
|
|
target_model_class: string | null;
|
|
fanout_strategy: 'max_parallel' | 'fill_one_first' | 'round_robin' | null;
|
|
}
|
|
export interface PipelineRunListResponse {
|
|
runs: PipelineRun[];
|
|
total: number;
|
|
}
|
|
|
|
export interface SliceResponse {
|
|
library_file_id: number;
|
|
name: string;
|
|
print_time_seconds: number;
|
|
filament_used_g: number;
|
|
filament_used_mm: number;
|
|
used_embedded_settings: boolean;
|
|
/** Why the result could not be written to the external folder the source
|
|
* lives in, and so went to managed storage instead. Null on every normal
|
|
* slice. Surfaced to the user — a file filed somewhere they aren't looking
|
|
* with no signal is what made #2810 invisible from the UI. */
|
|
external_write_fallback?: string | null;
|
|
}
|
|
|
|
export interface SliceArchiveResponse {
|
|
archive_id: number;
|
|
name: string;
|
|
print_time_seconds: number;
|
|
filament_used_g: number;
|
|
filament_used_mm: number;
|
|
used_embedded_settings: boolean;
|
|
}
|
|
|
|
// Background slice-job lifecycle. POST /slice returns 202 + this shape;
|
|
// the frontend polls /slice-jobs/{id} until status is terminal.
|
|
export type SliceJobStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
|
|
export interface SliceJobEnqueueResponse {
|
|
job_id: number;
|
|
status: SliceJobStatus;
|
|
status_url: string;
|
|
}
|
|
|
|
export interface SliceJobProgress {
|
|
/** Stage label emitted by the slicer ("Generating G-code", "Slicing finished"). */
|
|
stage: string;
|
|
total_percent: number;
|
|
plate_percent: number;
|
|
/** 1-indexed plate position; 0 means "all plates" / final completion. */
|
|
plate_index: number;
|
|
plate_count: number;
|
|
updated_at: number;
|
|
/** When the backend is in the cross-class slice-all loop (#1493), each
|
|
* per-plate sub-slice's progress is augmented with the loop position
|
|
* so the toast can show "Plate 2 of 5 — Generating G-code 47%". The
|
|
* fields are absent on a single-plate slice. */
|
|
multi_plate_index?: number;
|
|
multi_plate_count?: number;
|
|
}
|
|
|
|
export interface SliceJobState {
|
|
job_id: number;
|
|
status: SliceJobStatus;
|
|
kind: 'library_file' | 'archive';
|
|
source_id: number;
|
|
source_name: string;
|
|
created_at: string;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
/** Live progress fed by the sidecar's --pipe channel; null until the
|
|
* slicer emits its first frame (early "Initializing" phase) or when
|
|
* the sidecar doesn't support progress. */
|
|
progress: SliceJobProgress | null;
|
|
result?: SliceResponse | SliceArchiveResponse;
|
|
error_status?: number;
|
|
error_detail?: string;
|
|
}
|
|
|
|
// Local preset types (OrcaSlicer imports)
|
|
export interface LocalPreset {
|
|
id: number;
|
|
name: string;
|
|
preset_type: string;
|
|
source: string;
|
|
filament_type: string | null;
|
|
filament_vendor: string | null;
|
|
nozzle_temp_min: number | null;
|
|
nozzle_temp_max: number | null;
|
|
pressure_advance: string | null;
|
|
default_filament_colour: string | null;
|
|
filament_cost: string | null;
|
|
filament_density: string | null;
|
|
compatible_printers: string | null;
|
|
inherits: string | null;
|
|
version: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface LocalPresetDetail extends LocalPreset {
|
|
setting: Record<string, unknown>;
|
|
}
|
|
|
|
export interface LocalPresetsResponse {
|
|
filament: LocalPreset[];
|
|
printer: LocalPreset[];
|
|
process: LocalPreset[];
|
|
}
|
|
|
|
export interface ImportResponse {
|
|
success: boolean;
|
|
imported: number;
|
|
skipped: number;
|
|
errors: string[];
|
|
}
|
|
|
|
export interface FieldOption {
|
|
value: string;
|
|
label: string;
|
|
}
|
|
|
|
export interface FieldDefinition {
|
|
key: string;
|
|
label: string;
|
|
type: 'text' | 'number' | 'boolean' | 'select';
|
|
category: string;
|
|
description?: string;
|
|
options?: FieldOption[];
|
|
unit?: string;
|
|
min?: number;
|
|
max?: number;
|
|
step?: number;
|
|
}
|
|
|
|
export interface FieldDefinitionsResponse {
|
|
version: string;
|
|
description: string;
|
|
fields: FieldDefinition[];
|
|
}
|
|
|
|
export interface CloudDevice {
|
|
dev_id: string;
|
|
name: string;
|
|
dev_model_name: string | null;
|
|
dev_product_name: string | null;
|
|
online: boolean;
|
|
}
|
|
|
|
// Smart Plug types
|
|
export interface SmartPlug {
|
|
id: number;
|
|
name: string;
|
|
plug_type: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
|
|
ip_address: string | null; // Required for Tasmota
|
|
ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer")
|
|
// Home Assistant energy sensor entities (optional)
|
|
ha_power_entity: string | null;
|
|
ha_energy_today_entity: string | null;
|
|
ha_energy_total_entity: string | null;
|
|
// MQTT fields (required when plug_type="mqtt")
|
|
// Legacy field - kept for backward compatibility
|
|
mqtt_topic: string | null; // Deprecated, use mqtt_power_topic
|
|
mqtt_multiplier: number; // Deprecated, use mqtt_power_multiplier
|
|
// Power monitoring
|
|
mqtt_power_topic: string | null; // Topic for power data
|
|
mqtt_power_path: string | null; // e.g., "power_l1" or "data.power"
|
|
mqtt_power_multiplier: number; // Unit conversion for power
|
|
// Energy monitoring
|
|
mqtt_energy_topic: string | null; // Topic for energy data
|
|
mqtt_energy_path: string | null; // e.g., "energy_l1"
|
|
mqtt_energy_multiplier: number; // Unit conversion for energy
|
|
// State monitoring
|
|
mqtt_state_topic: string | null; // Topic for state data
|
|
mqtt_state_path: string | null; // e.g., "state_l1" for ON/OFF
|
|
mqtt_state_on_value: string | null; // What value means "ON" (e.g., "ON", "true", "1")
|
|
// REST/Webhook fields (required when plug_type="rest")
|
|
rest_on_url: string | null;
|
|
rest_on_body: string | null;
|
|
rest_off_url: string | null;
|
|
rest_off_body: string | null;
|
|
rest_method: string | null;
|
|
rest_headers: string | null;
|
|
rest_status_url: string | null;
|
|
rest_status_path: string | null;
|
|
rest_status_on_value: string | null;
|
|
rest_power_url: string | null;
|
|
rest_power_path: string | null;
|
|
rest_power_multiplier: number;
|
|
rest_energy_url: string | null;
|
|
rest_energy_path: string | null;
|
|
rest_energy_multiplier: number;
|
|
// Lifetime counter, separate from the daily one (#2539). A Shelly reports only
|
|
// this; Today and Yesterday are derived from its hourly snapshots.
|
|
rest_energy_total_path: string | null;
|
|
rest_energy_total_multiplier: number;
|
|
printer_id: number | null;
|
|
// #2629: only a plug that really feeds the printer may mark it offline when
|
|
// switched off. Accessory plugs follow the print cycle without powering it.
|
|
controls_printer_power: boolean;
|
|
enabled: boolean;
|
|
auto_on: boolean;
|
|
auto_off: boolean;
|
|
auto_off_persistent: boolean;
|
|
off_delay_mode: 'time' | 'temperature';
|
|
off_delay_minutes: number;
|
|
off_temp_threshold: number;
|
|
// #1349: auto-off after AMS drying completes.
|
|
auto_off_after_drying: boolean;
|
|
off_delay_after_drying_minutes: number;
|
|
username: string | null;
|
|
password: string | null;
|
|
// Power alerts
|
|
power_alert_enabled: boolean;
|
|
power_alert_high: number | null;
|
|
power_alert_low: number | null;
|
|
power_alert_last_triggered: string | null;
|
|
// Schedule
|
|
schedule_enabled: boolean;
|
|
schedule_on_time: string | null;
|
|
schedule_off_time: string | null;
|
|
// Visibility options
|
|
show_in_switchbar: boolean;
|
|
show_on_printer_card: boolean; // For scripts: show on printer card
|
|
// Status
|
|
last_state: string | null;
|
|
last_checked: string | null;
|
|
auto_off_executed: boolean; // True when auto-off was triggered after print
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface SmartPlugCreate {
|
|
name: string;
|
|
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
|
|
ip_address?: string | null; // Required for Tasmota
|
|
ha_entity_id?: string | null; // Required for Home Assistant
|
|
// Home Assistant energy sensor entities (optional)
|
|
ha_power_entity?: string | null;
|
|
ha_energy_today_entity?: string | null;
|
|
ha_energy_total_entity?: string | null;
|
|
// MQTT fields (required when plug_type="mqtt")
|
|
// Legacy fields - kept for backward compatibility
|
|
mqtt_topic?: string | null;
|
|
mqtt_multiplier?: number;
|
|
// Power monitoring
|
|
mqtt_power_topic?: string | null;
|
|
mqtt_power_path?: string | null;
|
|
mqtt_power_multiplier?: number;
|
|
// Energy monitoring
|
|
mqtt_energy_topic?: string | null;
|
|
mqtt_energy_path?: string | null;
|
|
mqtt_energy_multiplier?: number;
|
|
// State monitoring
|
|
mqtt_state_topic?: string | null;
|
|
mqtt_state_path?: string | null;
|
|
mqtt_state_on_value?: string | null;
|
|
// REST fields
|
|
rest_on_url?: string | null;
|
|
rest_on_body?: string | null;
|
|
rest_off_url?: string | null;
|
|
rest_off_body?: string | null;
|
|
rest_method?: string | null;
|
|
rest_headers?: string | null;
|
|
rest_status_url?: string | null;
|
|
rest_status_path?: string | null;
|
|
rest_status_on_value?: string | null;
|
|
rest_power_url?: string | null;
|
|
rest_power_path?: string | null;
|
|
rest_power_multiplier?: number;
|
|
rest_energy_url?: string | null;
|
|
rest_energy_path?: string | null;
|
|
rest_energy_multiplier?: number;
|
|
rest_energy_total_path?: string | null;
|
|
rest_energy_total_multiplier?: number;
|
|
printer_id?: number | null;
|
|
// #2629
|
|
controls_printer_power?: boolean;
|
|
enabled?: boolean;
|
|
auto_on?: boolean;
|
|
auto_off?: boolean;
|
|
auto_off_persistent?: boolean;
|
|
off_delay_mode?: 'time' | 'temperature';
|
|
off_delay_minutes?: number;
|
|
off_temp_threshold?: number;
|
|
// #1349
|
|
auto_off_after_drying?: boolean;
|
|
off_delay_after_drying_minutes?: number;
|
|
username?: string | null;
|
|
password?: string | null;
|
|
// Power alerts
|
|
power_alert_enabled?: boolean;
|
|
power_alert_high?: number | null;
|
|
power_alert_low?: number | null;
|
|
// Schedule
|
|
schedule_enabled?: boolean;
|
|
schedule_on_time?: string | null;
|
|
schedule_off_time?: string | null;
|
|
// Visibility options
|
|
show_in_switchbar?: boolean;
|
|
show_on_printer_card?: boolean;
|
|
}
|
|
|
|
export interface SmartPlugUpdate {
|
|
name?: string;
|
|
plug_type?: 'tasmota' | 'homeassistant' | 'mqtt' | 'rest';
|
|
ip_address?: string | null;
|
|
ha_entity_id?: string | null;
|
|
// Home Assistant energy sensor entities (optional)
|
|
ha_power_entity?: string | null;
|
|
ha_energy_today_entity?: string | null;
|
|
ha_energy_total_entity?: string | null;
|
|
// MQTT fields (legacy)
|
|
mqtt_topic?: string | null;
|
|
mqtt_multiplier?: number;
|
|
// MQTT power fields
|
|
mqtt_power_topic?: string | null;
|
|
mqtt_power_path?: string | null;
|
|
mqtt_power_multiplier?: number;
|
|
// MQTT energy fields
|
|
mqtt_energy_topic?: string | null;
|
|
mqtt_energy_path?: string | null;
|
|
mqtt_energy_multiplier?: number;
|
|
// MQTT state fields
|
|
mqtt_state_topic?: string | null;
|
|
mqtt_state_path?: string | null;
|
|
mqtt_state_on_value?: string | null;
|
|
// REST fields
|
|
rest_on_url?: string | null;
|
|
rest_on_body?: string | null;
|
|
rest_off_url?: string | null;
|
|
rest_off_body?: string | null;
|
|
rest_method?: string | null;
|
|
rest_headers?: string | null;
|
|
rest_status_url?: string | null;
|
|
rest_status_path?: string | null;
|
|
rest_status_on_value?: string | null;
|
|
rest_power_url?: string | null;
|
|
rest_power_path?: string | null;
|
|
rest_power_multiplier?: number;
|
|
rest_energy_url?: string | null;
|
|
rest_energy_path?: string | null;
|
|
rest_energy_multiplier?: number;
|
|
rest_energy_total_path?: string | null;
|
|
rest_energy_total_multiplier?: number;
|
|
printer_id?: number | null;
|
|
// #2629
|
|
controls_printer_power?: boolean;
|
|
enabled?: boolean;
|
|
auto_on?: boolean;
|
|
auto_off?: boolean;
|
|
auto_off_persistent?: boolean;
|
|
off_delay_mode?: 'time' | 'temperature';
|
|
off_delay_minutes?: number;
|
|
off_temp_threshold?: number;
|
|
// #1349
|
|
auto_off_after_drying?: boolean;
|
|
off_delay_after_drying_minutes?: number;
|
|
username?: string | null;
|
|
password?: string | null;
|
|
// Power alerts
|
|
power_alert_enabled?: boolean;
|
|
power_alert_high?: number | null;
|
|
power_alert_low?: number | null;
|
|
// Schedule
|
|
schedule_enabled?: boolean;
|
|
schedule_on_time?: string | null;
|
|
schedule_off_time?: string | null;
|
|
// Visibility options
|
|
show_in_switchbar?: boolean;
|
|
show_on_printer_card?: boolean;
|
|
}
|
|
|
|
// Home Assistant entity for smart plug selection
|
|
export interface HAEntity {
|
|
entity_id: string;
|
|
friendly_name: string;
|
|
state: string | null;
|
|
domain: string; // "switch", "light", "input_boolean", "script"
|
|
}
|
|
|
|
// Home Assistant sensor entity for energy monitoring
|
|
export interface HASensorEntity {
|
|
entity_id: string;
|
|
friendly_name: string;
|
|
state: string | null;
|
|
unit_of_measurement: string | null; // "W", "kW", "kWh", "Wh"
|
|
}
|
|
|
|
export interface HATestConnectionResult {
|
|
success: boolean;
|
|
message: string | null;
|
|
error: string | null;
|
|
}
|
|
|
|
// A Home Assistant entity bound to a printer for display on its card (#1148, #448).
|
|
// Read-only: unlike a SmartPlug there is nothing here to switch.
|
|
export interface PrinterHASensor {
|
|
id: number;
|
|
printer_id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class: string | null; // HA's own class: "door", "temperature", ...
|
|
unit: string | null; // numeric sensors only
|
|
// What counts as needing attention. Binary sensors use alert_state, numeric
|
|
// ones the thresholds; all null means the sensor is display-only.
|
|
alert_state: 'on' | 'off' | null;
|
|
alert_above: number | null;
|
|
alert_below: number | null;
|
|
block_print: boolean; // hold the printer's queue while alerting
|
|
notify_on_alert: boolean;
|
|
show_on_printer_card: boolean;
|
|
sort_order: number;
|
|
last_state: string | null;
|
|
last_changed: string | null;
|
|
last_checked: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface PrinterHASensorReading {
|
|
id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class: string | null;
|
|
unit: string | null;
|
|
state: string | null; // null when unreadable or not yet polled
|
|
value: number | null; // numeric sensors only
|
|
alerting: boolean;
|
|
block_print: boolean;
|
|
reachable: boolean;
|
|
last_changed: string | null;
|
|
}
|
|
|
|
export interface PrinterHASensorCreate {
|
|
printer_id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class?: string | null;
|
|
unit?: string | null;
|
|
alert_state?: 'on' | 'off' | null;
|
|
alert_above?: number | null;
|
|
alert_below?: number | null;
|
|
block_print?: boolean;
|
|
notify_on_alert?: boolean;
|
|
show_on_printer_card?: boolean;
|
|
sort_order?: number;
|
|
}
|
|
|
|
export type PrinterHASensorUpdate = Partial<Omit<PrinterHASensorCreate, 'printer_id'>>;
|
|
|
|
export interface LocationHASensor {
|
|
id: number;
|
|
location_id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class: string | null;
|
|
unit: string | null;
|
|
alert_state: 'on' | 'off' | null;
|
|
alert_above: number | null;
|
|
alert_below: number | null;
|
|
notify_on_alert: boolean;
|
|
show_on_card: boolean;
|
|
sort_order: number;
|
|
last_state: string | null;
|
|
last_changed: string | null;
|
|
last_checked: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface LocationHASensorReading {
|
|
id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class: string | null;
|
|
unit: string | null;
|
|
state: string | null;
|
|
value: number | null;
|
|
alerting: boolean;
|
|
reachable: boolean;
|
|
alert_state: string | null;
|
|
alert_above: number | null;
|
|
alert_below: number | null;
|
|
last_changed: string | null;
|
|
show_on_card: boolean;
|
|
}
|
|
|
|
export interface LocationHASensorCreate {
|
|
location_id: number;
|
|
name: string;
|
|
entity_id: string;
|
|
kind: 'binary' | 'numeric';
|
|
device_class?: string | null;
|
|
unit?: string | null;
|
|
alert_state?: 'on' | 'off' | null;
|
|
alert_above?: number | null;
|
|
alert_below?: number | null;
|
|
notify_on_alert?: boolean;
|
|
show_on_card?: boolean;
|
|
sort_order?: number;
|
|
}
|
|
|
|
export type LocationHASensorUpdate = Partial<Omit<LocationHASensorCreate, 'location_id'>>;
|
|
|
|
// An entity offered by the binding picker.
|
|
export interface HADisplayEntity {
|
|
entity_id: string;
|
|
friendly_name: string;
|
|
state: string | null;
|
|
domain: string; // "binary_sensor" | "sensor"
|
|
device_class: string | null;
|
|
unit_of_measurement: string | null;
|
|
}
|
|
|
|
export interface SmartPlugEnergy {
|
|
power: number | null; // Current watts
|
|
voltage: number | null; // Volts
|
|
current: number | null; // Amps
|
|
today: number | null; // kWh used today
|
|
yesterday: number | null; // kWh used yesterday
|
|
total: number | null; // Total kWh
|
|
factor: number | null; // Power factor (0-1)
|
|
apparent_power: number | null; // VA
|
|
reactive_power: number | null; // VAr
|
|
}
|
|
|
|
export interface SmartPlugStatus {
|
|
state: string | null;
|
|
reachable: boolean;
|
|
device_name: string | null;
|
|
energy: SmartPlugEnergy | null;
|
|
}
|
|
|
|
export interface SmartPlugTestResult {
|
|
success: boolean;
|
|
state: string | null;
|
|
device_name: string | null;
|
|
}
|
|
|
|
// Tasmota Discovery types
|
|
export interface TasmotaScanStatus {
|
|
running: boolean;
|
|
scanned: number;
|
|
total: number;
|
|
}
|
|
|
|
export interface DiscoveredTasmotaDevice {
|
|
ip_address: string;
|
|
name: string;
|
|
module: number | null;
|
|
state: string | null;
|
|
discovered_at: string | null;
|
|
}
|
|
|
|
// Print Queue types
|
|
export interface PrintQueueItem {
|
|
id: number;
|
|
printer_id: number | null; // null = unassigned
|
|
target_model: string | null; // Target printer model for model-based assignment
|
|
target_location: string | null; // Target location filter for model-based assignment
|
|
required_filament_types: string[] | null; // Required filament types for model-based assignment
|
|
waiting_reason: string | null; // Why a model-based job hasn't started yet
|
|
// Cross-model alternatives (#671), in priority order. Empty for ordinary
|
|
// items. Present until dispatch resolves one, after which library_file_id and
|
|
// target_model name the candidate that actually ran.
|
|
variants?: Array<{
|
|
library_file_id: number;
|
|
filename: string;
|
|
target_model: string;
|
|
position: number;
|
|
}>;
|
|
// Either archive_id OR library_file_id must be set (archive created at print start)
|
|
archive_id: number | null;
|
|
library_file_id: number | null;
|
|
cost_center_id: number | null;
|
|
estimated_cost: number | null;
|
|
position: number;
|
|
scheduled_time: string | null;
|
|
require_previous_success: boolean;
|
|
auto_off_after: boolean;
|
|
manual_start: boolean; // Requires manual trigger to start (staged)
|
|
// Set by the dispatch scheduler when the assigned spool can't satisfy
|
|
// any required slot's grams (#1496). Surfaced on the queue row as a
|
|
// "filament short" badge; cleared on a successful ▶ click (live recheck).
|
|
filament_short: boolean;
|
|
// Persistent "Print Anyway" acknowledgement — once true the scheduler
|
|
// skips the deficit check for this item (#1698-followup). Set by the
|
|
// start route when skip_filament_check=true, or at queue creation if
|
|
// PrintModal's deficit warning was acknowledged.
|
|
skip_filament_check: boolean;
|
|
// True when the source archive carries the slicer's own live-resolved
|
|
// AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
|
|
// exact physical spool instead of re-deriving one from type/color.
|
|
archive_has_slicer_ams_mapping: boolean;
|
|
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
|
|
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
|
|
plate_id: number | null; // Plate ID for multi-plate 3MF files
|
|
// Print options
|
|
bed_levelling: CalibrationMode;
|
|
flow_cali: CalibrationMode;
|
|
vibration_cali: boolean;
|
|
layer_inspect: boolean;
|
|
timelapse: boolean;
|
|
use_ams: boolean;
|
|
nozzle_offset_cali: CalibrationMode;
|
|
preheat_override: 'inherit' | 'on' | 'off';
|
|
preheat_chamber_target_override: number | null;
|
|
status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
error_message: string | null;
|
|
created_at: string;
|
|
archive_name?: string | null;
|
|
archive_thumbnail?: string | null;
|
|
// True when the linked archive has been soft-deleted; archive_name /
|
|
// archive_thumbnail / downstream metadata are left null in that case so
|
|
// the UI doesn't 404-storm the now-missing endpoints (#1348 follow-up).
|
|
archive_deleted?: boolean;
|
|
library_file_name?: string | null;
|
|
library_file_thumbnail?: string | null;
|
|
printer_name?: string | null;
|
|
print_time_seconds?: number | null; // Estimated print time from archive or library file
|
|
filament_used_grams?: number | null; // Estimated print weight from archive or library file
|
|
filament_type?: string | null; // e.g. "PLA", "PETG"
|
|
filament_color?: string | null; // Hex RGBA from the slicer
|
|
bed_type?: string | null; // Build plate type for this print (per-plate accurate, #1281)
|
|
// User tracking (Issue #206)
|
|
created_by_id?: number | null;
|
|
created_by_username?: string | null;
|
|
// Batch grouping
|
|
batch_id?: number | null;
|
|
batch_name?: string | null;
|
|
// Shortest-job-first scheduling
|
|
been_jumped?: boolean;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
cleanup_library_after_dispatch?: boolean;
|
|
/** Which rack position each filament group prints from, on a nozzle-rack
|
|
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
|
|
* the live rack at dispatch; omit to have the dispatcher assign them. */
|
|
nozzle_rack_choice?: Record<number, number> | null;
|
|
}
|
|
|
|
export interface PrintBatchPlateTarget {
|
|
plate_id: number | null;
|
|
plate_name?: string | null;
|
|
quantity_target: number;
|
|
sort_order?: number;
|
|
}
|
|
|
|
export interface PrintBatchPlateProgress {
|
|
plate_id: number | null;
|
|
plate_name: string | null;
|
|
quantity_target: number;
|
|
dispatched: number;
|
|
remaining: number;
|
|
pending_count: number;
|
|
printing_count: number;
|
|
completed_count: number;
|
|
failed_count: number;
|
|
cancelled_count: number;
|
|
skipped_count: number;
|
|
/** Measured from finished runs; null until one has produced a cost. */
|
|
actual_cost: number | null;
|
|
estimated_remaining_cost: number | null;
|
|
filament_used_grams: number | null;
|
|
print_time_seconds: number;
|
|
/** False when this plate owes runs but has no queue item left to clone
|
|
* their configuration from, so queueing it could only fail (#2960). */
|
|
can_dispatch: boolean;
|
|
}
|
|
|
|
export interface PrintBatch {
|
|
id: number;
|
|
name: string;
|
|
archive_id: number | null;
|
|
library_file_id: number | null;
|
|
quantity: number;
|
|
status: string;
|
|
created_at: string;
|
|
completed_at: string | null;
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
project_id: number | null;
|
|
due_date: string | null;
|
|
notes: string | null;
|
|
pending_count: number;
|
|
printing_count: number;
|
|
completed_count: number;
|
|
failed_count: number;
|
|
cancelled_count: number;
|
|
skipped_count: number;
|
|
/** False for batches created before per-plate targets existed (#342):
|
|
* they report progress but owe nothing and cannot be dispatched from. */
|
|
has_targets: boolean;
|
|
target_count: number;
|
|
remaining_count: number;
|
|
/** Of remaining_count, how many runs can actually be queued (#2960). */
|
|
dispatchable_count: number;
|
|
actual_cost: number | null;
|
|
estimated_remaining_cost: number | null;
|
|
filament_used_grams: number | null;
|
|
print_time_seconds: number;
|
|
plates: PrintBatchPlateProgress[];
|
|
}
|
|
|
|
export interface PrintQueueItemCreate {
|
|
printer_id?: number | null; // null = unassigned
|
|
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
|
|
target_location?: string | null; // Target location filter (only used with target_model)
|
|
filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
|
|
archive_id?: number | null;
|
|
library_file_id?: number | null;
|
|
scheduled_time?: string | null;
|
|
require_previous_success?: boolean;
|
|
auto_off_after?: boolean;
|
|
manual_start?: boolean; // Requires manual trigger to start (staged)
|
|
insert_at_top?: boolean; // Insert ahead of other pending items in the same queue scope
|
|
insert_position?: number | null; // 1-indexed insertion position for priority queueing
|
|
// PrintModal "Print Anyway" on the deficit warning — persisted so the
|
|
// scheduler doesn't immediately re-flag this item (#1698-followup).
|
|
skip_filament_check?: boolean;
|
|
ams_mapping?: number[] | null; // AMS slot mapping for multi-color prints
|
|
plate_id?: number | null; // Plate ID for multi-plate 3MF files
|
|
// Print options
|
|
bed_levelling?: CalibrationMode;
|
|
flow_cali?: CalibrationMode;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
nozzle_offset_cali?: CalibrationMode;
|
|
preheat_override?: 'inherit' | 'on' | 'off';
|
|
preheat_chamber_target_override?: number | null;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
// Batch: create multiple copies (creates a batch if > 1)
|
|
quantity?: number;
|
|
// Existing batch to add this item into (multi-plate auto-batch flow).
|
|
batch_id?: number | null;
|
|
// Project to associate the resulting archive with
|
|
project_id?: number;
|
|
cost_center_id?: number | null;
|
|
estimated_cost?: number | null;
|
|
// Delete transient uploaded library file after scheduler creates the archive
|
|
cleanup_library_after_dispatch?: boolean;
|
|
// Cross-model alternatives (#671): several sliced files, one job, whichever
|
|
// printer frees up first. Mutually exclusive with printer_id (a named printer
|
|
// defeats the point) and with archive_id/library_file_id (these ARE the files).
|
|
// Order is priority — index 0 wins when several printers are idle at once.
|
|
variants?: QueueVariantCreate[];
|
|
/** Which rack position each filament group prints from, on a nozzle-rack
|
|
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
|
|
* the live rack at dispatch; omit to have the dispatcher assign them. */
|
|
nozzle_rack_choice?: Record<number, number> | null;
|
|
}
|
|
|
|
/** One candidate file for a cross-model queue item (#671). */
|
|
export interface QueueVariantCreate {
|
|
library_file_id: number;
|
|
/** Read from the file's own sliced_for_model unless it declares none. */
|
|
target_model?: string | null;
|
|
plate_id?: number | null;
|
|
ams_mapping?: number[] | null;
|
|
nozzle_mapping?: number[] | null;
|
|
filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
|
|
}
|
|
|
|
export interface PrintBatchCreate {
|
|
name: string;
|
|
archive_id?: number | null;
|
|
library_file_id?: number | null;
|
|
/** When set, the listed pending items are assigned to the new batch
|
|
* (manual "Group as batch"). When omitted/empty, an empty batch is
|
|
* returned so the client can pass batch_id on subsequent addToQueue calls. */
|
|
item_ids?: number[];
|
|
/** Per-plate targets. Omitting them creates a plain grouping batch. */
|
|
plates?: PrintBatchPlateTarget[];
|
|
project_id?: number | null;
|
|
due_date?: string | null;
|
|
notes?: string | null;
|
|
}
|
|
|
|
export interface PrintBatchUpdate {
|
|
name?: string;
|
|
status?: 'active' | 'cancelled';
|
|
/** Replaces the full target set — a plate omitted here is removed. */
|
|
plates?: PrintBatchPlateTarget[];
|
|
project_id?: number | null;
|
|
due_date?: string | null;
|
|
notes?: string | null;
|
|
}
|
|
|
|
export interface PrintBatchDispatchRequest {
|
|
plate_id?: number | null;
|
|
only_plate?: boolean;
|
|
/** Cap on items created across all plates; omit to queue everything owed. */
|
|
limit?: number;
|
|
}
|
|
|
|
export interface PrintQueueItemUpdate {
|
|
printer_id?: number | null; // null = unassign
|
|
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
|
|
target_location?: string | null; // Target location filter (only used with target_model)
|
|
filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
|
|
position?: number;
|
|
scheduled_time?: string | null;
|
|
require_previous_success?: boolean;
|
|
auto_off_after?: boolean;
|
|
manual_start?: boolean;
|
|
ams_mapping?: number[];
|
|
plate_id?: number | null; // Plate ID for multi-plate 3MF files
|
|
// Print options
|
|
bed_levelling?: CalibrationMode;
|
|
flow_cali?: CalibrationMode;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
nozzle_offset_cali?: CalibrationMode;
|
|
preheat_override?: 'inherit' | 'on' | 'off';
|
|
preheat_chamber_target_override?: number | null;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
cost_center_id?: number | null;
|
|
estimated_cost?: number | null;
|
|
/** Which rack position each filament group prints from, on a nozzle-rack
|
|
* machine (#1784): `{ [group_id]: 1-based position }`. Re-checked against
|
|
* the live rack at dispatch; omit to have the dispatcher assign them. */
|
|
nozzle_rack_choice?: Record<number, number> | null;
|
|
}
|
|
|
|
export interface PrintQueueBulkUpdate {
|
|
item_ids: number[];
|
|
printer_id?: number | null;
|
|
scheduled_time?: string | null;
|
|
require_previous_success?: boolean;
|
|
auto_off_after?: boolean;
|
|
manual_start?: boolean;
|
|
// Print options
|
|
bed_levelling?: CalibrationMode;
|
|
flow_cali?: CalibrationMode;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
nozzle_offset_cali?: CalibrationMode;
|
|
preheat_override?: 'inherit' | 'on' | 'off';
|
|
preheat_chamber_target_override?: number | null;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
cost_center_id?: number | null;
|
|
estimated_cost?: number | null;
|
|
}
|
|
|
|
export interface PrintQueueBulkUpdateResponse {
|
|
updated_count: number;
|
|
skipped_count: number;
|
|
message: string;
|
|
}
|
|
|
|
// MQTT Logging types
|
|
export interface MQTTLogEntry {
|
|
timestamp: string;
|
|
topic: string;
|
|
direction: 'in' | 'out';
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
export interface MQTTLogsResponse {
|
|
logging_enabled: boolean;
|
|
logs: MQTTLogEntry[];
|
|
}
|
|
|
|
// K-Profile types
|
|
export interface KProfile {
|
|
slot_id: number;
|
|
extruder_id: number;
|
|
nozzle_id: string;
|
|
nozzle_diameter: string;
|
|
filament_id: string;
|
|
name: string;
|
|
k_value: string;
|
|
n_coef: string;
|
|
ams_id: number;
|
|
tray_id: number;
|
|
setting_id: string | null;
|
|
}
|
|
|
|
export interface KProfileCreate {
|
|
slot_id?: number; // Storage slot, 0 for new profiles
|
|
extruder_id?: number;
|
|
nozzle_id: string;
|
|
nozzle_diameter: string;
|
|
filament_id: string;
|
|
name: string;
|
|
k_value: string;
|
|
n_coef?: string;
|
|
ams_id?: number;
|
|
tray_id?: number;
|
|
setting_id?: string | null;
|
|
}
|
|
|
|
export interface KProfileDelete {
|
|
slot_id: number; // cali_idx - calibration index to delete
|
|
extruder_id: number;
|
|
nozzle_id: string; // e.g., "HH00-0.4"
|
|
nozzle_diameter: string; // e.g., "0.4"
|
|
filament_id: string; // Bambu filament identifier
|
|
setting_id?: string | null; // Setting ID (for X1C series)
|
|
}
|
|
|
|
export interface KProfilesResponse {
|
|
profiles: KProfile[];
|
|
nozzle_diameter: string;
|
|
}
|
|
|
|
export interface KProfileNote {
|
|
setting_id: string;
|
|
note: string;
|
|
}
|
|
|
|
export interface KProfileNotesResponse {
|
|
notes: Record<string, string>; // setting_id -> note
|
|
}
|
|
|
|
// Slot Preset Mapping
|
|
export interface SlotPresetMapping {
|
|
ams_id: number;
|
|
tray_id: number;
|
|
preset_id: string;
|
|
preset_name: string;
|
|
}
|
|
|
|
// Filament types
|
|
export interface Filament {
|
|
id: number;
|
|
name: string;
|
|
type: string; // PLA, PETG, ABS, etc.
|
|
brand: string | null;
|
|
color: string | null;
|
|
color_hex: string | null;
|
|
cost_per_kg: number;
|
|
spool_weight_g: number;
|
|
currency: string;
|
|
density: number | null;
|
|
print_temp_min: number | null;
|
|
print_temp_max: number | null;
|
|
bed_temp_min: number | null;
|
|
bed_temp_max: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
// Notification Provider types
|
|
export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark';
|
|
|
|
export interface NotificationProvider {
|
|
id: number;
|
|
name: string;
|
|
provider_type: ProviderType;
|
|
enabled: boolean;
|
|
config: Record<string, unknown>;
|
|
// Print lifecycle events
|
|
on_print_start: boolean;
|
|
on_print_complete: boolean;
|
|
on_print_failed: boolean;
|
|
on_print_stopped: boolean;
|
|
on_print_progress: boolean;
|
|
on_print_missing_spool_assignment: boolean;
|
|
on_billing_charge_failed: boolean;
|
|
// Printer status events
|
|
on_printer_offline: boolean;
|
|
on_printer_error: boolean;
|
|
on_ai_failure_detection: boolean;
|
|
on_filament_low: boolean;
|
|
on_maintenance_due: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high: boolean;
|
|
on_ams_temperature_high: boolean;
|
|
on_ams_drying_suspended: boolean;
|
|
// AMS-HT environmental alarms
|
|
on_ams_ht_humidity_high: boolean;
|
|
on_ams_ht_temperature_high: boolean;
|
|
// Build plate detection
|
|
on_plate_not_empty: boolean;
|
|
on_plate_clear_required: boolean;
|
|
// Bed cooled
|
|
on_bed_cooled: boolean;
|
|
on_ha_sensor_alert: boolean;
|
|
on_location_ha_sensor_alert: boolean;
|
|
// First layer complete
|
|
on_first_layer_complete: boolean;
|
|
// Inventory stock alerts
|
|
on_stock_reorder_alert: boolean;
|
|
on_stock_break_alert: boolean;
|
|
// Print queue events
|
|
on_queue_job_added: boolean;
|
|
on_queue_job_assigned: boolean;
|
|
on_queue_job_started: boolean;
|
|
on_queue_job_waiting: boolean;
|
|
on_queue_job_skipped: boolean;
|
|
on_queue_job_failed: boolean;
|
|
on_queue_completed: boolean;
|
|
// Quiet hours
|
|
quiet_hours_enabled: boolean;
|
|
quiet_hours_start: string | null;
|
|
quiet_hours_end: string | null;
|
|
// Daily digest
|
|
daily_digest_enabled: boolean;
|
|
daily_digest_time: string | null;
|
|
// Printer filter
|
|
printer_id: number | null;
|
|
// Status tracking
|
|
last_success: string | null;
|
|
last_error: string | null;
|
|
last_error_at: string | null;
|
|
// Timestamps
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface NotificationProviderCreate {
|
|
name: string;
|
|
provider_type: ProviderType;
|
|
enabled?: boolean;
|
|
config: Record<string, unknown>;
|
|
// Print lifecycle events
|
|
on_print_start?: boolean;
|
|
on_print_complete?: boolean;
|
|
on_print_failed?: boolean;
|
|
on_print_stopped?: boolean;
|
|
on_print_progress?: boolean;
|
|
on_print_missing_spool_assignment?: boolean;
|
|
on_billing_charge_failed?: boolean;
|
|
// Printer status events
|
|
on_printer_offline?: boolean;
|
|
on_printer_error?: boolean;
|
|
on_ai_failure_detection?: boolean;
|
|
on_filament_low?: boolean;
|
|
on_maintenance_due?: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high?: boolean;
|
|
on_ams_temperature_high?: boolean;
|
|
on_ams_drying_suspended?: boolean;
|
|
// AMS-HT environmental alarms
|
|
on_ams_ht_humidity_high?: boolean;
|
|
on_ams_ht_temperature_high?: boolean;
|
|
// Build plate detection
|
|
on_plate_not_empty?: boolean;
|
|
on_plate_clear_required?: boolean;
|
|
// Bed cooled
|
|
on_bed_cooled?: boolean;
|
|
on_ha_sensor_alert?: boolean;
|
|
on_location_ha_sensor_alert?: boolean;
|
|
// First layer complete
|
|
on_first_layer_complete?: boolean;
|
|
// Inventory stock alerts
|
|
on_stock_reorder_alert?: boolean;
|
|
on_stock_break_alert?: boolean;
|
|
// Print queue events
|
|
on_queue_job_added?: boolean;
|
|
on_queue_job_assigned?: boolean;
|
|
on_queue_job_started?: boolean;
|
|
on_queue_job_waiting?: boolean;
|
|
on_queue_job_skipped?: boolean;
|
|
on_queue_job_failed?: boolean;
|
|
on_queue_completed?: boolean;
|
|
// Quiet hours
|
|
quiet_hours_enabled?: boolean;
|
|
quiet_hours_start?: string | null;
|
|
quiet_hours_end?: string | null;
|
|
// Daily digest
|
|
daily_digest_enabled?: boolean;
|
|
daily_digest_time?: string | null;
|
|
// Printer filter
|
|
printer_id?: number | null;
|
|
}
|
|
|
|
export interface NotificationProviderUpdate {
|
|
name?: string;
|
|
provider_type?: ProviderType;
|
|
enabled?: boolean;
|
|
config?: Record<string, unknown>;
|
|
// Print lifecycle events
|
|
on_print_start?: boolean;
|
|
on_print_complete?: boolean;
|
|
on_print_failed?: boolean;
|
|
on_print_stopped?: boolean;
|
|
on_print_progress?: boolean;
|
|
on_print_missing_spool_assignment?: boolean;
|
|
on_billing_charge_failed?: boolean;
|
|
// Printer status events
|
|
on_printer_offline?: boolean;
|
|
on_printer_error?: boolean;
|
|
on_ai_failure_detection?: boolean;
|
|
on_filament_low?: boolean;
|
|
on_maintenance_due?: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high?: boolean;
|
|
on_ams_temperature_high?: boolean;
|
|
on_ams_drying_suspended?: boolean;
|
|
// AMS-HT environmental alarms
|
|
on_ams_ht_humidity_high?: boolean;
|
|
on_ams_ht_temperature_high?: boolean;
|
|
// Build plate detection
|
|
on_plate_not_empty?: boolean;
|
|
on_plate_clear_required?: boolean;
|
|
// Bed cooled
|
|
on_bed_cooled?: boolean;
|
|
on_ha_sensor_alert?: boolean;
|
|
on_location_ha_sensor_alert?: boolean;
|
|
// First layer complete
|
|
on_first_layer_complete?: boolean;
|
|
// Inventory stock alerts
|
|
on_stock_reorder_alert?: boolean;
|
|
on_stock_break_alert?: boolean;
|
|
// Print queue events
|
|
on_queue_job_added?: boolean;
|
|
on_queue_job_assigned?: boolean;
|
|
on_queue_job_started?: boolean;
|
|
on_queue_job_waiting?: boolean;
|
|
on_queue_job_skipped?: boolean;
|
|
on_queue_job_failed?: boolean;
|
|
on_queue_completed?: boolean;
|
|
// Quiet hours
|
|
quiet_hours_enabled?: boolean;
|
|
quiet_hours_start?: string | null;
|
|
quiet_hours_end?: string | null;
|
|
// Daily digest
|
|
daily_digest_enabled?: boolean;
|
|
daily_digest_time?: string | null;
|
|
// Printer filter
|
|
printer_id?: number | null;
|
|
}
|
|
|
|
// GitHub Backup types
|
|
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
|
|
export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
|
|
|
|
/** How many cloud accounts a backup would collect presets from. Counts only —
|
|
* with auth enabled the accounts belong to individual users, so the backup
|
|
* administrator sees how many, never whose. */
|
|
export interface CloudAccountCounts {
|
|
bambu: number;
|
|
orca: number;
|
|
}
|
|
|
|
export interface GitHubBackupConfig {
|
|
id: number;
|
|
repository_url: string;
|
|
has_token: boolean;
|
|
branch: string;
|
|
provider: GitProviderType;
|
|
allow_insecure_http: boolean;
|
|
schedule_enabled: boolean;
|
|
schedule_type: ScheduleType;
|
|
backup_kprofiles: boolean;
|
|
backup_cloud_profiles: boolean;
|
|
backup_settings: boolean;
|
|
backup_spools: boolean;
|
|
backup_archives: boolean;
|
|
enabled: boolean;
|
|
last_backup_at: string | null;
|
|
last_backup_status: string | null;
|
|
last_backup_message: string | null;
|
|
last_backup_commit_sha: string | null;
|
|
next_scheduled_run: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface GitHubBackupConfigCreate {
|
|
repository_url: string;
|
|
access_token: string;
|
|
branch?: string;
|
|
provider?: GitProviderType;
|
|
allow_insecure_http?: boolean;
|
|
schedule_enabled?: boolean;
|
|
schedule_type?: ScheduleType;
|
|
backup_kprofiles?: boolean;
|
|
backup_cloud_profiles?: boolean;
|
|
backup_settings?: boolean;
|
|
backup_spools?: boolean;
|
|
backup_archives?: boolean;
|
|
enabled?: boolean;
|
|
}
|
|
|
|
export interface GitHubBackupLog {
|
|
id: number;
|
|
config_id: number;
|
|
started_at: string;
|
|
completed_at: string | null;
|
|
status: string;
|
|
trigger: string;
|
|
commit_sha: string | null;
|
|
files_changed: number;
|
|
error_message: string | null;
|
|
}
|
|
|
|
export interface GitHubBackupStatus {
|
|
configured: boolean;
|
|
enabled: boolean;
|
|
is_running: boolean;
|
|
restore_running: boolean;
|
|
progress: string | null;
|
|
last_backup_at: string | null;
|
|
last_backup_status: string | null;
|
|
next_scheduled_run: string | null;
|
|
}
|
|
|
|
// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
|
|
// the backup collector never writes them, so there is nothing to restore.
|
|
export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
|
|
|
|
export interface GitHubCommitInfo {
|
|
sha: string;
|
|
message: string;
|
|
author: string;
|
|
date: string;
|
|
}
|
|
|
|
export interface GitHubCommitListResponse {
|
|
success: boolean;
|
|
message: string;
|
|
branch: string;
|
|
commits: GitHubCommitInfo[];
|
|
}
|
|
|
|
/**
|
|
* Values the server interpolates into a translated note or preview detail.
|
|
* Kept to strings and numbers on purpose — anything richer would have to be
|
|
* formatted server-side and could not be translated.
|
|
*/
|
|
export type GitHubRestoreParams = Record<string, string | number>;
|
|
|
|
export interface GitHubRestorePreviewCategory {
|
|
category: RestoreCategory;
|
|
available: boolean;
|
|
item_count: number;
|
|
/** English rendering. Used as i18next's defaultValue, never shown on its own. */
|
|
detail: string | null;
|
|
/** Key under backup.restoreFromGit.details, or null when there is no caveat. */
|
|
detail_code: string | null;
|
|
detail_params: GitHubRestoreParams;
|
|
}
|
|
|
|
export interface GitHubRestorePreview {
|
|
success: boolean;
|
|
message: string;
|
|
ref: string;
|
|
commit: GitHubCommitInfo | null;
|
|
metadata_version: string | null;
|
|
categories: GitHubRestorePreviewCategory[];
|
|
}
|
|
|
|
export interface GitHubRestoreRequest {
|
|
ref?: string;
|
|
categories: RestoreCategory[];
|
|
overwrite_existing?: boolean;
|
|
}
|
|
|
|
/**
|
|
* One tally note, as a translation code plus its parameters (#2656).
|
|
*
|
|
* Same contract as {@link LocalBackupPathCheck} one card down: the server picks
|
|
* the code and supplies typed params, and the client renders
|
|
* ``t(`backup.restoreFromGit.notes.${code}`, { ...params, defaultValue: message })``.
|
|
* A code the client does not know yet falls back to the English `message`
|
|
* rather than showing the raw key.
|
|
*/
|
|
export interface GitHubRestoreNote {
|
|
code: string;
|
|
params: GitHubRestoreParams;
|
|
message: string;
|
|
}
|
|
|
|
export interface GitHubRestoreCategoryResult {
|
|
restored: number;
|
|
skipped: number;
|
|
failed: number;
|
|
notes: GitHubRestoreNote[];
|
|
}
|
|
|
|
export interface GitHubRestoreResponse {
|
|
success: boolean;
|
|
message: string;
|
|
log_id: number | null;
|
|
ref: string | null;
|
|
results: Record<string, GitHubRestoreCategoryResult>;
|
|
}
|
|
|
|
export interface LocalBackupStatus {
|
|
enabled: boolean;
|
|
schedule: string;
|
|
time: string;
|
|
retention: number;
|
|
path: string;
|
|
default_path: string;
|
|
is_running: boolean;
|
|
last_backup_at: string | null;
|
|
last_status: string | null;
|
|
last_message: string | null;
|
|
next_run: string | null;
|
|
timezone: string;
|
|
}
|
|
|
|
export interface LocalBackupFile {
|
|
filename: string;
|
|
size: number;
|
|
created_at: string;
|
|
}
|
|
|
|
/** Result of writing a probe file into the configured backup directory (#2544). */
|
|
export interface LocalBackupPathCheck {
|
|
writable: boolean;
|
|
path: string;
|
|
/** 'ok' | 'sandboxed' | 'read_only' | 'permission_denied' | 'no_space' | 'not_a_directory' | 'missing' | 'error' */
|
|
code: string;
|
|
/** Raw OS error, shown as-is under the translated explanation. */
|
|
detail: string | null;
|
|
/** Copy-pasteable fix (systemd drop-in, compose snippet) — not translated. */
|
|
remedy: string | null;
|
|
message: string;
|
|
/** 'container_ephemeral' — writable, but the backups die with the container. */
|
|
warning: string | null;
|
|
}
|
|
|
|
export interface ObicoDetectionEvent {
|
|
printer_id: number;
|
|
task_name: string;
|
|
timestamp: string;
|
|
current_p: number;
|
|
score: number;
|
|
class: 'safe' | 'warning' | 'failure';
|
|
detections: number;
|
|
}
|
|
|
|
export interface ObicoStatus {
|
|
is_running: boolean;
|
|
last_error: string | null;
|
|
per_printer: Record<string, { class: string; frame_count: number; score: number }>;
|
|
thresholds: { low: number; high: number };
|
|
history: ObicoDetectionEvent[];
|
|
enabled: boolean;
|
|
ml_url: string;
|
|
sensitivity: 'low' | 'medium' | 'high';
|
|
action: 'notify' | 'pause' | 'pause_and_off';
|
|
poll_interval: number;
|
|
external_url_configured: boolean;
|
|
}
|
|
|
|
// Lightweight subset served under printers:read for the printer-card badge (#1546)
|
|
export interface ObicoPrinterStatus {
|
|
enabled: boolean;
|
|
// null = all printers are monitored
|
|
monitored_printers: number[] | null;
|
|
per_printer: Record<string, { class: string; frame_count: number; score: number }>;
|
|
// null = no error, or the viewer lacks settings:read (error strings can embed config URLs)
|
|
last_error: string | null;
|
|
}
|
|
|
|
export interface ObicoTestConnection {
|
|
ok: boolean;
|
|
status_code: number | null;
|
|
body: string | null;
|
|
error: string | null;
|
|
// Whether the ML API accepted the token. null = not determined (the health
|
|
// check failed first, or the token probe itself errored).
|
|
auth_ok: boolean | null;
|
|
}
|
|
|
|
export interface GitHubTestConnectionResponse {
|
|
success: boolean;
|
|
message: string;
|
|
repo_name: string | null;
|
|
permissions: Record<string, boolean> | null;
|
|
// true = confirmed private, false = confirmed public/internal,
|
|
// null = could not determine. Backend rejects save unless true.
|
|
is_private: boolean | null;
|
|
}
|
|
|
|
export interface GitHubBackupTriggerResponse {
|
|
success: boolean;
|
|
message: string;
|
|
log_id: number | null;
|
|
commit_sha: string | null;
|
|
files_changed: number;
|
|
}
|
|
|
|
export interface NotificationTestRequest {
|
|
provider_type: ProviderType;
|
|
config: Record<string, unknown>;
|
|
}
|
|
|
|
export interface NotificationTestResponse {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
// Provider-specific config types for reference
|
|
export interface CallMeBotConfig {
|
|
phone: string;
|
|
apikey: string;
|
|
}
|
|
|
|
export interface NtfyConfig {
|
|
server?: string;
|
|
topic: string;
|
|
auth_token?: string | null;
|
|
}
|
|
|
|
export interface PushoverConfig {
|
|
user_key: string;
|
|
app_token: string;
|
|
priority?: number;
|
|
}
|
|
|
|
export interface TelegramConfig {
|
|
bot_token: string;
|
|
chat_id: string;
|
|
}
|
|
|
|
export interface EmailConfig {
|
|
smtp_server: string;
|
|
smtp_port?: number;
|
|
username: string;
|
|
password: string;
|
|
from_email: string;
|
|
to_email: string;
|
|
use_tls?: boolean;
|
|
}
|
|
|
|
// Notification Template types
|
|
export interface NotificationTemplate {
|
|
id: number;
|
|
event_type: string;
|
|
name: string;
|
|
title_template: string;
|
|
body_template: string;
|
|
is_default: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface NotificationTemplateUpdate {
|
|
title_template?: string;
|
|
body_template?: string;
|
|
}
|
|
|
|
export interface EventVariablesResponse {
|
|
event_type: string;
|
|
event_name: string;
|
|
variables: string[];
|
|
}
|
|
|
|
export interface TemplatePreviewRequest {
|
|
event_type: string;
|
|
title_template: string;
|
|
body_template: string;
|
|
}
|
|
|
|
export interface TemplatePreviewResponse {
|
|
title: string;
|
|
body: string;
|
|
}
|
|
|
|
// Notification Log types
|
|
export interface NotificationLogEntry {
|
|
id: number;
|
|
provider_id: number;
|
|
provider_name: string | null;
|
|
provider_type: string | null;
|
|
event_type: string;
|
|
title: string;
|
|
message: string;
|
|
success: boolean;
|
|
error_message: string | null;
|
|
printer_id: number | null;
|
|
printer_name: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface NotificationLogStats {
|
|
total: number;
|
|
success_count: number;
|
|
failure_count: number;
|
|
by_event_type: Record<string, number>;
|
|
by_provider: Record<string, number>;
|
|
}
|
|
|
|
// Spoolman types
|
|
export interface SpoolmanStatus {
|
|
enabled: boolean;
|
|
connected: boolean;
|
|
url: string | null;
|
|
}
|
|
|
|
export interface SkippedSpool {
|
|
location: string;
|
|
reason: string;
|
|
filament_type: string | null;
|
|
color: string | null;
|
|
}
|
|
|
|
export interface SpoolmanSyncResult {
|
|
success: boolean;
|
|
synced_count: number;
|
|
skipped_count: number;
|
|
skipped: SkippedSpool[];
|
|
errors: string[];
|
|
}
|
|
|
|
export interface UnlinkedSpool {
|
|
id: number;
|
|
filament_name: string | null;
|
|
filament_vendor: string | null;
|
|
filament_material: string | null;
|
|
filament_color_hex: string | null;
|
|
remaining_weight: number | null;
|
|
location: string | null;
|
|
}
|
|
|
|
export interface LinkedSpoolInfo {
|
|
id: number;
|
|
remaining_weight: number | null;
|
|
filament_weight: number | null;
|
|
}
|
|
|
|
export interface LinkedSpoolsMap {
|
|
linked: Record<string, LinkedSpoolInfo>; // tag (uppercase) -> spool info
|
|
}
|
|
|
|
export interface SpoolmanVendor {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface SpoolmanFilamentEntry {
|
|
id: number;
|
|
name: string;
|
|
material: string | null;
|
|
color_hex: string | null;
|
|
color_name: string | null;
|
|
weight: number | null;
|
|
spool_weight: number | null;
|
|
vendor: SpoolmanVendor | null;
|
|
}
|
|
|
|
// Inventory types
|
|
// Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
|
|
export type SpoolLabelTemplate =
|
|
| 'ams_holder_74x33'
|
|
| 'ams_holder_75x55'
|
|
| 'box_40x30'
|
|
| 'box_62x29'
|
|
| 'avery_5160'
|
|
| 'avery_l7160';
|
|
|
|
export interface PrintSpoolLabelsRequest {
|
|
spool_ids: number[];
|
|
template: SpoolLabelTemplate;
|
|
monochrome: boolean;
|
|
starting_position: number;
|
|
}
|
|
|
|
export interface InventorySpool {
|
|
id: number;
|
|
material: string;
|
|
subtype: string | null;
|
|
color_name: string | null;
|
|
// True when color_name was synthesised from subtype because Spoolman has no
|
|
// stored value (Spoolman-backed inventory only). The edit form uses this to
|
|
// leave the input blank, so the user doesn't round-trip the synth value
|
|
// back to Spoolman as if it were a real user-set color_name (#1319).
|
|
color_name_is_synthesized?: boolean;
|
|
rgba: string | null;
|
|
// Multi-colour gradient stops (#1154): comma-separated 6/8-char hex.
|
|
extra_colors: string | null;
|
|
// Visual effect overlay: sparkle | wood | marble | glow | matte.
|
|
effect_type: string | null;
|
|
brand: string | null;
|
|
label_weight: number;
|
|
core_weight: number;
|
|
core_weight_catalog_id: number | null;
|
|
weight_used: number;
|
|
// Anchor for the resettable "Total Consumed" display (#1390). The
|
|
// counter shown on the Inventory page is `weight_used - weight_used_baseline`;
|
|
// remaining is still `label_weight - weight_used`, so "Reset usage to 0"
|
|
// zeroes the counter without disturbing remaining. Optional for back-compat
|
|
// with rows from a pre-migration DB snapshot — default to 0.
|
|
weight_used_baseline?: number;
|
|
slicer_filament: string | null;
|
|
slicer_filament_name: string | null;
|
|
nozzle_temp_min: number | null;
|
|
nozzle_temp_max: number | null;
|
|
note: string | null;
|
|
added_full: boolean | null;
|
|
last_used: string | null;
|
|
encode_time: string | null;
|
|
tag_uid: string | null;
|
|
tray_uuid: string | null;
|
|
data_origin: string | null;
|
|
tag_type: string | null;
|
|
archived_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
cost_per_kg: number | null;
|
|
last_scale_weight: number | null;
|
|
last_weighed_at: string | null;
|
|
// User-defined category + per-spool low-stock threshold override (#729).
|
|
category: string | null;
|
|
low_stock_threshold_pct: number | null;
|
|
k_profiles?: SpoolKProfile[];
|
|
storage_location?: string | null;
|
|
location_id?: number | null;
|
|
}
|
|
|
|
export interface SpoolmanBulkCreateResult {
|
|
created: InventorySpool[];
|
|
requested_count: number;
|
|
failed_count: number;
|
|
}
|
|
|
|
// ── CSV import/export (#1576) ──────────────────────────────────────────────
|
|
/** One row's outcome from the import preview / real import. */
|
|
export interface CsvImportRow {
|
|
row_number: number;
|
|
status: 'valid' | 'error' | 'skipped';
|
|
reason: string | null;
|
|
material: string | null;
|
|
brand: string | null;
|
|
color_name: string | null;
|
|
rgba: string | null;
|
|
/** rgba/extra_colors/effect_type were filled from the Color Catalog. */
|
|
resolved_color: boolean;
|
|
/** The catalog match came from a different material's variant (no exact
|
|
* material match). Shown as a warning in the preview. */
|
|
cross_material_color: boolean;
|
|
/** An active spool with the same material+brand+color already exists.
|
|
* Informational only — the import still creates the row. */
|
|
duplicate_of_existing: boolean;
|
|
}
|
|
|
|
/** Dry-run preview: per-row classification, no rows written. */
|
|
export interface CsvImportPreview {
|
|
columns: string[];
|
|
total: number;
|
|
valid_count: number;
|
|
error_count: number;
|
|
skipped_count: number;
|
|
rows: CsvImportRow[];
|
|
warnings: string[];
|
|
}
|
|
|
|
/** Summary returned after a real (non-dry-run) import. */
|
|
export interface CsvImportResult {
|
|
created: number;
|
|
skipped: number;
|
|
errors: number;
|
|
error_rows: CsvImportRow[];
|
|
}
|
|
|
|
export interface SpoolUsageRecord {
|
|
id: number;
|
|
spool_id: number;
|
|
printer_id: number | null;
|
|
print_name: string | null;
|
|
weight_used: number;
|
|
percent_used: number;
|
|
status: string;
|
|
cost: number | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface SpoolKProfile {
|
|
id: number;
|
|
spool_id: number;
|
|
printer_id: number;
|
|
extruder: number;
|
|
nozzle_diameter: string;
|
|
nozzle_type: string | null;
|
|
k_value: number;
|
|
name: string | null;
|
|
cali_idx: number | null;
|
|
setting_id: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface SpoolKProfileInput {
|
|
printer_id: number;
|
|
extruder?: number;
|
|
nozzle_diameter?: string;
|
|
nozzle_type?: string | null;
|
|
k_value: number;
|
|
name?: string | null;
|
|
cali_idx?: number | null;
|
|
setting_id?: string | null;
|
|
}
|
|
|
|
/**
|
|
* One per-printer-model override of a spool's slicer filament preset.
|
|
*
|
|
* A cloud or Orca preset is bound to a printer model ("@BBL X1C"), so the
|
|
* spool's single `slicer_filament` is wrong as soon as the spool is used on a
|
|
* second model. `nozzle_diameter` is "" for the model's own default and a bare
|
|
* decimal ("0.2") for a per-hotend exception; the backend resolves
|
|
* exact (model, diameter) -> (model, "") -> the spool's own preset.
|
|
*/
|
|
export interface SlotSpoolDefaults {
|
|
slicer_filament: string | null;
|
|
slicer_filament_name: string | null;
|
|
cali_idx: number | null;
|
|
k_value: number | null;
|
|
profile_name: string | null;
|
|
extruder: number | null;
|
|
nozzle_diameter: string;
|
|
}
|
|
|
|
export interface SpoolFilamentPresetInput {
|
|
printer_model: string;
|
|
nozzle_diameter: string;
|
|
slicer_filament: string | null;
|
|
slicer_filament_name: string | null;
|
|
}
|
|
|
|
export interface SpoolFilamentPreset extends SpoolFilamentPresetInput {
|
|
id: number;
|
|
spool_id: number;
|
|
created_at: string;
|
|
}
|
|
|
|
/** One inventory-bound AMS slot, as returned by `/printers/{id}/inventory-remain`. */
|
|
export interface SlotMaterial {
|
|
ams_id: number;
|
|
tray_id: number;
|
|
global_tray_id: number;
|
|
/** Opaque grouping key from the backend. Two slots back each other up under
|
|
* AMS Filament Backup only when both `material_key` and `extruder` match.
|
|
* Never parse it — the format belongs to the backend's identity rule. */
|
|
material_key: string;
|
|
remaining_g: number;
|
|
/** 0 = right / single nozzle, 1 = left. */
|
|
extruder: number;
|
|
/** How the bound spool should be *named* — display only, never matched on.
|
|
* The printer has no brand field and reports no sub-brand for a
|
|
* third-party spool, so this is the only place a slot's real identity
|
|
* exists. Null when the binding resolves to a spool we cannot describe. */
|
|
spool?: SlotSpoolIdentity | null;
|
|
}
|
|
|
|
/** Display identity of an inventory-bound slot. See `SlotMaterial.spool`. */
|
|
export interface SlotSpoolIdentity {
|
|
brand: string | null;
|
|
material: string | null;
|
|
subtype: string | null;
|
|
color_name: string | null;
|
|
rgba: string | null;
|
|
}
|
|
|
|
export interface InventoryRemainResponse {
|
|
/** Currently-loaded, inventory-bound slots only — drives the prefer-lowest sort. */
|
|
inventory_remain_g: Record<string, number>;
|
|
/** Every inventory binding on the printer, with identity + extruder side. */
|
|
slot_materials: SlotMaterial[];
|
|
}
|
|
|
|
export interface SpoolAssignment {
|
|
id: number;
|
|
spool_id: number;
|
|
printer_id: number;
|
|
printer_name: string | null;
|
|
ams_id: number;
|
|
tray_id: number;
|
|
fingerprint_color: string | null;
|
|
fingerprint_type: string | null;
|
|
spool?: InventorySpool | null;
|
|
configured: boolean;
|
|
pending_config?: boolean; // Slot was empty at assign time; will configure on insert
|
|
created_at: string;
|
|
ams_label?: string | null; // User-defined friendly name for the AMS unit
|
|
}
|
|
|
|
export interface FilamentSkuSettings {
|
|
id: number;
|
|
material: string;
|
|
subtype: string | null;
|
|
brand: string | null;
|
|
color_name: string | null;
|
|
lead_time_days: number;
|
|
safety_margin_value: number;
|
|
safety_margin_unit: 'days' | 'g';
|
|
alerts_snoozed: boolean;
|
|
}
|
|
|
|
export interface ShoppingListItem {
|
|
id: number;
|
|
material: string;
|
|
subtype: string | null;
|
|
brand: string | null;
|
|
color_name: string | null;
|
|
quantity_spools: number;
|
|
note: string | null;
|
|
status: 'pending' | 'purchased' | 'received';
|
|
purchased_at: string | null;
|
|
added_at: string;
|
|
}
|
|
|
|
export interface ShoppingListItemCreate {
|
|
material: string;
|
|
subtype: string | null;
|
|
brand: string | null;
|
|
color_name: string | null;
|
|
quantity_spools: number;
|
|
note?: string | null;
|
|
}
|
|
|
|
// Update types
|
|
export interface VersionInfo {
|
|
version: string;
|
|
repo: string;
|
|
}
|
|
|
|
export interface UpdateCheckResult {
|
|
update_available: boolean;
|
|
current_version: string;
|
|
latest_version: string | null;
|
|
release_name?: string;
|
|
release_notes?: string;
|
|
release_url?: string;
|
|
published_at?: string;
|
|
error?: string;
|
|
message?: string;
|
|
is_docker?: boolean;
|
|
is_ha_addon?: boolean;
|
|
is_windows_installer?: boolean;
|
|
update_method?: 'docker' | 'git' | 'ha_addon' | 'windows_installer';
|
|
installer_download_url?: string | null;
|
|
// Best-effort guess at the compose directory (#2664). Prefill only — the
|
|
// value the user saved lives on AppSettings.docker_compose_dir, so clearing
|
|
// that field falls back to this guess instead of resurrecting the old value.
|
|
compose_dir_detected?: string | null;
|
|
}
|
|
|
|
export interface UpdateStatus {
|
|
status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';
|
|
progress: number;
|
|
message: string;
|
|
error: string | null;
|
|
}
|
|
|
|
// Maintenance types
|
|
export interface MaintenanceType {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
default_interval_hours: number;
|
|
interval_type: 'hours' | 'days'; // "hours" = print hours, "days" = calendar days
|
|
icon: string | null;
|
|
wiki_url: string | null; // Documentation link
|
|
is_system: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface MaintenanceTypeCreate {
|
|
name: string;
|
|
description?: string | null;
|
|
default_interval_hours?: number;
|
|
interval_type?: 'hours' | 'days';
|
|
icon?: string | null;
|
|
wiki_url?: string | null;
|
|
}
|
|
|
|
export interface MaintenanceStatus {
|
|
id: number;
|
|
printer_id: number;
|
|
printer_name: string;
|
|
printer_model: string | null;
|
|
maintenance_type_id: number;
|
|
maintenance_type_name: string;
|
|
maintenance_type_icon: string | null;
|
|
maintenance_type_wiki_url: string | null; // Custom wiki URL from type
|
|
enabled: boolean;
|
|
interval_hours: number; // For hours type: print hours; for days type: number of days
|
|
interval_type: 'hours' | 'days';
|
|
current_hours: number;
|
|
hours_since_maintenance: number;
|
|
hours_until_due: number;
|
|
days_since_maintenance: number | null; // For days type
|
|
days_until_due: number | null; // For days type
|
|
is_due: boolean;
|
|
is_warning: boolean;
|
|
last_performed_at: string | null;
|
|
}
|
|
|
|
export interface PrinterMaintenanceOverview {
|
|
printer_id: number;
|
|
printer_name: string;
|
|
printer_model: string | null;
|
|
total_print_hours: number;
|
|
maintenance_items: MaintenanceStatus[];
|
|
due_count: number;
|
|
warning_count: number;
|
|
}
|
|
|
|
export interface MaintenanceHistory {
|
|
id: number;
|
|
printer_maintenance_id: number;
|
|
performed_at: string;
|
|
hours_at_maintenance: number;
|
|
notes: string | null;
|
|
}
|
|
|
|
export interface MaintenanceSummary {
|
|
total_due: number;
|
|
total_warning: number;
|
|
printers_with_issues: Array<{
|
|
printer_id: number;
|
|
printer_name: string;
|
|
due_count: number;
|
|
warning_count: number;
|
|
}>;
|
|
}
|
|
|
|
// External Links (sidebar)
|
|
export interface ExternalLink {
|
|
id: number;
|
|
name: string;
|
|
url: string;
|
|
icon: string;
|
|
open_in_new_tab: boolean;
|
|
custom_icon: string | null;
|
|
sort_order: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ExternalLinkCreate {
|
|
name: string;
|
|
url: string;
|
|
icon: string;
|
|
open_in_new_tab?: boolean;
|
|
}
|
|
|
|
export interface ExternalLinkUpdate {
|
|
name?: string;
|
|
url?: string;
|
|
icon?: string;
|
|
open_in_new_tab?: boolean;
|
|
}
|
|
|
|
// Finance types
|
|
export interface CostCenterSummary {
|
|
id: number;
|
|
name: string;
|
|
is_private: boolean;
|
|
owner_user_id: number | null;
|
|
is_active: boolean;
|
|
total_balance: number;
|
|
total_budget: number | null;
|
|
monthly_budget: number | null;
|
|
budget_mode: 'none' | 'total' | 'monthly';
|
|
budget_limit: number | null;
|
|
budget_used: number | null;
|
|
budget_available: number | null;
|
|
can_print: boolean;
|
|
}
|
|
|
|
export interface CostCenterCreateRequest {
|
|
name: string;
|
|
total_budget?: number | null;
|
|
monthly_budget?: number | null;
|
|
is_active?: boolean;
|
|
}
|
|
|
|
export interface CostCenterBudgetUpdateRequest {
|
|
total_budget?: number | null;
|
|
monthly_budget?: number | null;
|
|
}
|
|
|
|
export interface CostCenterUpdateRequest {
|
|
name?: string;
|
|
is_active?: boolean;
|
|
}
|
|
|
|
export interface CostCenterMemberRequest {
|
|
user_id: number;
|
|
can_print?: boolean;
|
|
}
|
|
|
|
export interface CostCenterMemberResponse {
|
|
id: number;
|
|
cost_center_id: number;
|
|
user_id: number;
|
|
can_print: boolean;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CostCenterDetail extends CostCenterSummary {
|
|
members: CostCenterMemberResponse[];
|
|
}
|
|
|
|
export interface WalletBalance {
|
|
user_id: number;
|
|
balance: number;
|
|
currency: string;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export type WalletTransactionType = 'print_charge' | 'deposit' | 'withdraw' | 'manual_adjustment';
|
|
|
|
export interface WalletTransaction {
|
|
id: number;
|
|
user_id: number;
|
|
cost_center_id: number | null;
|
|
transaction_type: WalletTransactionType;
|
|
amount: number;
|
|
balance_after: number | null;
|
|
description: string | null;
|
|
created_by_user_id: number | null;
|
|
print_run_id: string | null;
|
|
print_archive_id: number | null;
|
|
print_queue_id: number | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface WalletTransactionListResponse {
|
|
items: WalletTransaction[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
export interface WalletAdjustmentRequest {
|
|
amount: number;
|
|
description?: string;
|
|
cost_center_id?: number | null;
|
|
}
|
|
|
|
export interface WalletAdjustmentResponse {
|
|
transaction: WalletTransaction;
|
|
balance: WalletBalance;
|
|
}
|
|
|
|
export interface TransactionEditRequest {
|
|
user_id?: number | null;
|
|
cost_center_id?: number | null;
|
|
amount?: number | null;
|
|
description?: string | null;
|
|
}
|
|
|
|
export interface ManualPrintRequest {
|
|
user_id: number;
|
|
cost_center_id: number;
|
|
amount: number;
|
|
description?: string | null;
|
|
created_at?: string | null;
|
|
}
|
|
|
|
// Permission type - all available permissions
|
|
export type Permission =
|
|
| 'printers:read' | 'printers:create' | 'printers:update' | 'printers:delete' | 'printers:control' | 'printers:files' | 'printers:ams_rfid' | 'printers:clear_plate'
|
|
| 'archives:read' | 'archives:read_own' | 'archives:read_all' | 'archives:create'
|
|
| 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
|
|
| 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
|
|
| 'queue:read' | 'queue:read_own' | 'queue:read_all' | 'queue:create'
|
|
| 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
|
|
| 'queue:reorder'
|
|
| 'library:read' | 'library:read_own' | 'library:read_all' | 'library:upload'
|
|
| 'library:update_own' | 'library:update_all' | 'library:delete_own' | 'library:delete_all'
|
|
| 'library:purge'
|
|
| 'projects:read' | 'projects:create' | 'projects:update' | 'projects:delete'
|
|
| 'filaments:read' | 'filaments:create' | 'filaments:update' | 'filaments:delete'
|
|
| 'inventory:read' | 'inventory:create' | 'inventory:update' | 'inventory:delete' | 'inventory:view_assignments'
|
|
| 'inventory:forecast_read' | 'inventory:forecast_write'
|
|
| 'smart_plugs:read' | 'smart_plugs:create' | 'smart_plugs:update' | 'smart_plugs:delete' | 'smart_plugs:control'
|
|
| 'camera:view'
|
|
| 'maintenance:read' | 'maintenance:create' | 'maintenance:update' | 'maintenance:delete'
|
|
| 'kprofiles:read' | 'kprofiles:create' | 'kprofiles:update' | 'kprofiles:delete'
|
|
| 'notifications:read' | 'notifications:create' | 'notifications:update' | 'notifications:delete' | 'notifications:user_email'
|
|
| 'notification_templates:read' | 'notification_templates:update'
|
|
| 'external_links:read' | 'external_links:create' | 'external_links:update' | 'external_links:delete'
|
|
| 'discovery:scan'
|
|
| 'firmware:read' | 'firmware:update'
|
|
| 'ams_history:read'
|
|
| 'cost_centers:read_own' | 'cost_centers:read_all' | 'cost_centers:modify' | 'cost_centers:create'
|
|
| 'stats:read' | 'stats:filter_by_user'
|
|
| 'system:read'
|
|
| 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
|
|
| 'github:backup' | 'github:restore'
|
|
| 'cloud:auth' | 'orca_cloud:auth'
|
|
| 'makerworld:view' | 'makerworld:import'
|
|
| 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
|
|
| 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete'
|
|
| 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
|
|
| 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
|
|
| 'websocket:connect';
|
|
|
|
// Group types
|
|
export interface GroupBrief {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface Group {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
permissions: Permission[];
|
|
is_system: boolean;
|
|
user_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface GroupDetail extends Group {
|
|
users: Array<{ id: number; username: string; is_active: boolean }>;
|
|
}
|
|
|
|
export interface GroupCreate {
|
|
name: string;
|
|
description?: string;
|
|
permissions: Permission[];
|
|
}
|
|
|
|
export interface GroupUpdate {
|
|
name?: string;
|
|
description?: string;
|
|
permissions?: Permission[];
|
|
}
|
|
|
|
export interface PermissionInfo {
|
|
value: Permission;
|
|
label: string;
|
|
}
|
|
|
|
export interface PermissionCategory {
|
|
name: string;
|
|
permissions: PermissionInfo[];
|
|
}
|
|
|
|
export interface PermissionsListResponse {
|
|
categories: PermissionCategory[];
|
|
all_permissions: Permission[];
|
|
}
|
|
|
|
// User email notification preferences
|
|
export interface UserEmailPreferences {
|
|
notify_print_start: boolean;
|
|
notify_print_complete: boolean;
|
|
notify_print_failed: boolean;
|
|
notify_print_stopped: boolean;
|
|
}
|
|
|
|
// Auth types
|
|
export interface LoginRequest {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
access_token?: string;
|
|
token_type?: string;
|
|
user?: UserResponse;
|
|
/** Set when 2FA verification is required before a full token is issued. */
|
|
requires_2fa?: boolean;
|
|
pre_auth_token?: string;
|
|
two_fa_methods?: string[];
|
|
}
|
|
|
|
export interface UserResponse {
|
|
id: number;
|
|
username: string;
|
|
email?: string;
|
|
role: string; // Deprecated, kept for backward compatibility
|
|
is_active: boolean;
|
|
is_admin: boolean; // Computed from role and group membership
|
|
auth_source: string; // "local" or "ldap"
|
|
groups: GroupBrief[];
|
|
permissions: Permission[]; // All permissions from groups
|
|
created_at: string;
|
|
}
|
|
|
|
/**
|
|
* Just enough to label an owner id (#1894). Backed by GET /users/slim, which
|
|
* is readable with `users:read_slim` as well as the admin-level `users:read`
|
|
* -- use it anywhere a screen only needs to turn a `created_by_id` into a
|
|
* name, so operators are not forced into the full listing to get one.
|
|
*/
|
|
export interface UserSlim {
|
|
id: number;
|
|
username: string;
|
|
}
|
|
|
|
export interface UserCreate {
|
|
username: string;
|
|
password?: string; // Optional when advanced auth is enabled
|
|
email?: string;
|
|
role: string;
|
|
group_ids?: number[];
|
|
}
|
|
|
|
export interface UserUpdate {
|
|
username?: string;
|
|
password?: string;
|
|
email?: string;
|
|
role?: string;
|
|
is_active?: boolean;
|
|
group_ids?: number[];
|
|
}
|
|
|
|
export interface SetupRequest {
|
|
auth_enabled: boolean;
|
|
admin_username?: string;
|
|
admin_password?: string;
|
|
}
|
|
|
|
export interface ForgotPasswordRequest {
|
|
email: string;
|
|
}
|
|
|
|
export interface ForgotPasswordResponse {
|
|
message: string;
|
|
}
|
|
|
|
export interface ResetPasswordRequest {
|
|
user_id: number;
|
|
}
|
|
|
|
export interface ResetPasswordResponse {
|
|
message: string;
|
|
}
|
|
|
|
export interface SMTPSettings {
|
|
smtp_host: string;
|
|
smtp_port: number;
|
|
smtp_username?: string;
|
|
smtp_password?: string;
|
|
smtp_security: 'starttls' | 'ssl' | 'none';
|
|
smtp_auth_enabled: boolean;
|
|
smtp_from_email: string;
|
|
smtp_from_name: string;
|
|
}
|
|
|
|
// 2FA / MFA interfaces
|
|
export interface TwoFAStatus {
|
|
totp_enabled: boolean;
|
|
email_otp_enabled: boolean;
|
|
backup_codes_remaining: number;
|
|
}
|
|
|
|
export interface TOTPSetupResponse {
|
|
secret: string;
|
|
qr_code_b64: string;
|
|
issuer: string;
|
|
}
|
|
|
|
export interface TOTPEnableResponse {
|
|
message: string;
|
|
backup_codes: string[];
|
|
}
|
|
|
|
export interface BackupCodesResponse {
|
|
backup_codes: string[];
|
|
message: string;
|
|
}
|
|
|
|
export interface TwoFAVerifyRequest {
|
|
pre_auth_token: string;
|
|
code: string;
|
|
method: 'totp' | 'email' | 'backup';
|
|
}
|
|
|
|
/**
|
|
* A URL that is known to be same-origin (a relative path starting with ``/``).
|
|
*
|
|
* Branded so that producers of same-origin URLs (e.g. ``api.oidcProviderIconUrl``)
|
|
* can be distinguished from arbitrary strings at the type level. The brand
|
|
* is compile-time only; at runtime these are plain strings.
|
|
*
|
|
* Purpose: CSP-safe image sources for ``<img src=...>``. The strict
|
|
* ``img-src 'self' data: blob:`` CSP rejects anything that isn't same-origin,
|
|
* so callers that demand a ``SameOriginUrl`` get a compile-time guarantee
|
|
* that no external URL slips through.
|
|
*/
|
|
export type SameOriginUrl = string & { readonly __brand: 'SameOriginUrl' };
|
|
|
|
// OIDC interfaces
|
|
export interface OIDCProvider {
|
|
id: number;
|
|
name: string;
|
|
issuer_url: string;
|
|
client_id: string;
|
|
scopes: string;
|
|
is_enabled: boolean;
|
|
auto_create_users: boolean;
|
|
auto_link_existing_accounts: boolean;
|
|
email_claim: string;
|
|
require_email_verified: boolean;
|
|
icon_url?: string | null;
|
|
default_group_id?: number | null;
|
|
// True when the backend has cached icon bytes for this provider.
|
|
// Login page / admin preview consume this via the proxy URL
|
|
// /api/v1/auth/oidc/providers/{id}/icon (#1333) so the SPA never
|
|
// hotlinks the external icon URL — that would require loosening
|
|
// the strict img-src CSP. Required, not optional: the backend always
|
|
// includes this field in the response (Pydantic default-False is
|
|
// populated unconditionally in the route handler).
|
|
has_icon: boolean;
|
|
// #1589: when true, the LoginPage redirects unauthenticated visitors
|
|
// straight to this provider on mount. At most one provider may carry this.
|
|
is_autologin: boolean;
|
|
// #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
|
|
// every boot. The API answers 409 to any write, so the settings UI must not
|
|
// offer edit/delete controls that cannot succeed. Optional so a response
|
|
// from an older backend still type-checks.
|
|
is_env_managed?: boolean;
|
|
}
|
|
|
|
export interface OIDCProviderCreate {
|
|
name: string;
|
|
issuer_url: string;
|
|
client_id: string;
|
|
client_secret: string;
|
|
scopes?: string;
|
|
is_enabled?: boolean;
|
|
auto_create_users?: boolean;
|
|
auto_link_existing_accounts?: boolean;
|
|
email_claim?: string;
|
|
require_email_verified?: boolean;
|
|
icon_url?: string | null;
|
|
default_group_id?: number | null;
|
|
is_autologin?: boolean; // #1589
|
|
}
|
|
|
|
export interface OIDCLink {
|
|
id: number;
|
|
provider_id: number;
|
|
provider_name: string;
|
|
provider_email?: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface TestSMTPRequest {
|
|
test_recipient: string;
|
|
}
|
|
|
|
export interface TestSMTPResponse {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export interface AdvancedAuthStatus {
|
|
advanced_auth_enabled: boolean;
|
|
smtp_configured: boolean;
|
|
// #1589: false hides the username/password form on the LoginPage; the env
|
|
// var BAMBUDDY_LOCAL_LOGIN=true on the server flips this back to true so
|
|
// the recovery path remains visible.
|
|
local_login_enabled: boolean;
|
|
// #1589: when set, LoginPage redirects to this provider's authorize URL
|
|
// on mount unless ?fallback=local is in the URL or the redirect times out.
|
|
autologin_provider_id: number | null;
|
|
}
|
|
|
|
export interface LDAPStatus {
|
|
ldap_enabled: boolean;
|
|
ldap_configured: boolean;
|
|
}
|
|
|
|
export interface EncryptionRowCounts {
|
|
oidc_providers: number;
|
|
user_totp: number;
|
|
}
|
|
|
|
export interface EncryptionStatus {
|
|
key_configured: boolean;
|
|
key_source: 'env' | 'file' | 'generated' | 'none';
|
|
legacy_plaintext_rows: EncryptionRowCounts;
|
|
encrypted_rows: EncryptionRowCounts;
|
|
decryption_broken: boolean;
|
|
// B2: count of rows skipped during the last legacy re-encryption migration.
|
|
// Surfaced via a yellow secondary banner in SecurityStatusCard.
|
|
migration_error_count: number;
|
|
}
|
|
|
|
export interface LDAPTestResponse {
|
|
success: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export interface LDAPSearchResult {
|
|
username: string;
|
|
email: string | null;
|
|
display_name: string | null;
|
|
dn: string;
|
|
already_provisioned: boolean;
|
|
}
|
|
|
|
export interface SetupResponse {
|
|
auth_enabled: boolean;
|
|
admin_created?: boolean;
|
|
}
|
|
|
|
export interface AuthStatus {
|
|
auth_enabled: boolean;
|
|
requires_setup: boolean;
|
|
}
|
|
|
|
// API functions
|
|
export const api = {
|
|
// Authentication
|
|
getAuthStatus: () => request<AuthStatus>('/auth/status'),
|
|
setupAuth: (data: SetupRequest) =>
|
|
request<SetupResponse>('/auth/setup', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
login: (data: LoginRequest) =>
|
|
request<LoginResponse>('/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
logout: () =>
|
|
request<{ message: string }>('/auth/logout', {
|
|
method: 'POST',
|
|
}),
|
|
getCurrentUser: () => request<UserResponse>('/auth/me'),
|
|
disableAuth: () =>
|
|
request<{ message: string; auth_enabled: boolean }>('/auth/disable', {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Advanced Authentication
|
|
testSMTP: (data: TestSMTPRequest) =>
|
|
request<TestSMTPResponse>('/auth/smtp/test', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getSMTPSettings: () => request<SMTPSettings | null>('/auth/smtp'),
|
|
saveSMTPSettings: (data: SMTPSettings) =>
|
|
request<{ message: string }>('/auth/smtp', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
enableAdvancedAuth: () =>
|
|
request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/enable', {
|
|
method: 'POST',
|
|
}),
|
|
disableAdvancedAuth: () =>
|
|
request<{ message: string; advanced_auth_enabled: boolean }>('/auth/advanced-auth/disable', {
|
|
method: 'POST',
|
|
}),
|
|
getAdvancedAuthStatus: () => request<AdvancedAuthStatus>('/auth/advanced-auth/status'),
|
|
// LDAP Authentication
|
|
getLDAPStatus: () => request<LDAPStatus>('/auth/ldap/status'),
|
|
getEncryptionStatus: () => request<EncryptionStatus>('/auth/encryption-status'),
|
|
testLDAP: () =>
|
|
request<LDAPTestResponse>('/auth/ldap/test', {
|
|
method: 'POST',
|
|
}),
|
|
searchLDAPDirectory: (q: string) =>
|
|
request<LDAPSearchResult[]>(`/auth/ldap/search?q=${encodeURIComponent(q)}`),
|
|
provisionLDAPUser: (username: string) =>
|
|
request<UserResponse>('/auth/ldap/provision', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ username }),
|
|
}),
|
|
forgotPassword: (data: ForgotPasswordRequest) =>
|
|
request<ForgotPasswordResponse>('/auth/forgot-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
// H-6: Confirm password reset using the token from the emailed link
|
|
forgotPasswordConfirm: (token: string, newPassword: string) =>
|
|
request<ForgotPasswordResponse>('/auth/forgot-password/confirm', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ token, new_password: newPassword }),
|
|
}),
|
|
resetUserPassword: (data: ResetPasswordRequest) =>
|
|
request<ResetPasswordResponse>('/auth/reset-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// 2FA - status
|
|
get2FAStatus: () => request<TwoFAStatus>('/auth/2fa/status'),
|
|
|
|
// 2FA - TOTP
|
|
setupTOTP: () => request<TOTPSetupResponse>('/auth/2fa/totp/setup', { method: 'POST' }),
|
|
enableTOTP: (code: string) =>
|
|
request<TOTPEnableResponse>('/auth/2fa/totp/enable', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ code }),
|
|
}),
|
|
disableTOTP: (code: string) =>
|
|
request<{ message: string }>('/auth/2fa/totp/disable', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ code }),
|
|
}),
|
|
regenerateBackupCodes: (code: string) =>
|
|
request<BackupCodesResponse>('/auth/2fa/totp/regenerate-backup-codes', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ code }),
|
|
}),
|
|
|
|
// 2FA - Email OTP
|
|
// Step 1: send a verification code to the user's email (proof of possession)
|
|
enableEmailOTP: () =>
|
|
request<{ message: string; setup_token: string }>('/auth/2fa/email/enable', { method: 'POST' }),
|
|
// Step 2: confirm with the code received by email
|
|
confirmEnableEmailOTP: (setup_token: string, code: string) =>
|
|
request<{ message: string }>('/auth/2fa/email/enable/confirm', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ setup_token, code }),
|
|
}),
|
|
// Disable requires account password for re-auth
|
|
disableEmailOTP: (password: string) =>
|
|
request<{ message: string }>('/auth/2fa/email/disable', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ password }),
|
|
}),
|
|
sendEmailOTP: (preAuthToken: string) =>
|
|
request<{ message: string; pre_auth_token?: string }>('/auth/2fa/email/send', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ pre_auth_token: preAuthToken }),
|
|
}),
|
|
|
|
// 2FA - verify (completes login)
|
|
verify2FA: (data: TwoFAVerifyRequest) =>
|
|
request<LoginResponse>('/auth/2fa/verify', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// 2FA - admin
|
|
admin2FADisable: (userId: number) =>
|
|
request<{ message: string }>(`/auth/2fa/admin/${userId}`, { method: 'DELETE' }),
|
|
|
|
// OIDC providers (public list)
|
|
getOIDCProviders: () => request<OIDCProvider[]>('/auth/oidc/providers'),
|
|
|
|
// OIDC providers (admin)
|
|
getOIDCProvidersAll: () => request<OIDCProvider[]>('/auth/oidc/providers/all'),
|
|
createOIDCProvider: (data: OIDCProviderCreate) =>
|
|
request<OIDCProvider>('/auth/oidc/providers', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateOIDCProvider: (id: number, data: Partial<OIDCProviderCreate>) =>
|
|
request<OIDCProvider>(`/auth/oidc/providers/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteOIDCProvider: (id: number) =>
|
|
request<{ message: string }>(`/auth/oidc/providers/${id}`, { method: 'DELETE' }),
|
|
|
|
// OIDC provider icon proxy (#1333) — same-origin path so the strict
|
|
// img-src CSP stays in force. Returns a SameOriginUrl-branded string
|
|
// so a future caller can't accidentally substitute an attacker-
|
|
// controlled URL where this is consumed.
|
|
oidcProviderIconUrl: (id: number): SameOriginUrl =>
|
|
`/api/v1/auth/oidc/providers/${id}/icon` as SameOriginUrl,
|
|
deleteOIDCProviderIcon: (id: number) =>
|
|
request<void>(`/auth/oidc/providers/${id}/icon`, { method: 'DELETE' }),
|
|
refreshOIDCProviderIcon: (id: number) =>
|
|
request<OIDCProvider>(`/auth/oidc/providers/${id}/icon/refresh`, { method: 'POST' }),
|
|
|
|
// OIDC authorize URL
|
|
getOIDCAuthorizeUrl: (providerId: number) =>
|
|
request<{ auth_url: string }>(`/auth/oidc/authorize/${providerId}`),
|
|
|
|
// OIDC exchange token for JWT
|
|
exchangeOIDCToken: (oidcToken: string) =>
|
|
request<LoginResponse>('/auth/oidc/exchange', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ oidc_token: oidcToken }),
|
|
}),
|
|
|
|
// OIDC links for current user
|
|
getOIDCLinks: () => request<OIDCLink[]>('/auth/oidc/links'),
|
|
deleteOIDCLink: (providerId: number) =>
|
|
request<{ message: string }>(`/auth/oidc/links/${providerId}`, { method: 'DELETE' }),
|
|
|
|
// Users
|
|
getUsers: () => request<UserResponse[]>('/users/'),
|
|
getUsersSlim: () => request<UserSlim[]>('/users/slim'),
|
|
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
|
createUser: (data: UserCreate) =>
|
|
request<UserResponse>('/users/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateUser: (id: number, data: UserUpdate) =>
|
|
request<UserResponse>(`/users/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteUser: (id: number, deleteItems: boolean = false) =>
|
|
request<void>(`/users/${id}?delete_items=${deleteItems}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
getUserItemsCount: (id: number) =>
|
|
request<{ archives: number; queue_items: number; library_files: number }>(`/users/${id}/items-count`),
|
|
changePassword: (currentPassword: string, newPassword: string) =>
|
|
request<{ message: string }>('/users/me/change-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
|
}),
|
|
|
|
// User Email Notifications
|
|
getUserEmailPreferences: () =>
|
|
request<UserEmailPreferences>('/user-notifications/preferences'),
|
|
updateUserEmailPreferences: (data: UserEmailPreferences) =>
|
|
request<UserEmailPreferences>('/user-notifications/preferences', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// Groups
|
|
getPermissions: () => request<PermissionsListResponse>('/groups/permissions'),
|
|
getGroups: () => request<Group[]>('/groups/'),
|
|
getGroup: (id: number) => request<GroupDetail>(`/groups/${id}`),
|
|
createGroup: (data: GroupCreate) =>
|
|
request<Group>('/groups/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateGroup: (id: number, data: GroupUpdate) =>
|
|
request<Group>(`/groups/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteGroup: (id: number) =>
|
|
request<void>(`/groups/${id}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
addUserToGroup: (groupId: number, userId: number) =>
|
|
request<void>(`/groups/${groupId}/users/${userId}`, {
|
|
method: 'POST',
|
|
}),
|
|
removeUserFromGroup: (groupId: number, userId: number) =>
|
|
request<void>(`/groups/${groupId}/users/${userId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// Printers
|
|
getPrinters: () => request<Printer[]>('/printers/'),
|
|
getPrinter: (id: number) => request<Printer>(`/printers/${id}`),
|
|
createPrinter: (data: PrinterCreate) =>
|
|
request<Printer>('/printers/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updatePrinter: (id: number, data: Partial<PrinterCreate>) =>
|
|
request<Printer>(`/printers/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deletePrinter: (id: number, deleteArchives: boolean = true) =>
|
|
request<{ status: string; archives_deleted: boolean }>(
|
|
`/printers/${id}?delete_archives=${deleteArchives}`,
|
|
{ method: 'DELETE' }
|
|
),
|
|
getDeveloperModeWarnings: () =>
|
|
request<{ printer_id: number; name: string }[]>('/printers/developer-mode-warnings'),
|
|
getAvailableFilaments: (model: string, location?: string) => {
|
|
const params = new URLSearchParams({ model });
|
|
if (location) params.set('location', location);
|
|
return request<Array<{ type: string; color: string; tray_info_idx: string; tray_sub_brands: string; extruder_id: number | null }>>(`/printers/available-filaments?${params}`);
|
|
},
|
|
getPrinterStatus: (id: number) =>
|
|
request<PrinterStatus>(`/printers/${id}/status`),
|
|
refreshPrinterStatus: (id: number) =>
|
|
request<{ status: string }>(`/printers/${id}/refresh-status`, {
|
|
method: 'POST',
|
|
}),
|
|
connectPrinter: (id: number) =>
|
|
request<{ connected: boolean }>(`/printers/${id}/connect`, {
|
|
method: 'POST',
|
|
}),
|
|
disconnectPrinter: (id: number) =>
|
|
request<{ connected: boolean }>(`/printers/${id}/disconnect`, {
|
|
method: 'POST',
|
|
}),
|
|
testExternalCamera: (printerId: number, url: string, cameraType: string) =>
|
|
// `coalesced` is true when the frame came from a capture that was already
|
|
// running (Obico polling, a snapshot) rather than a connection this test
|
|
// opened — a single-reader camera is shared rather than opened twice, so
|
|
// the result is real but says nothing about reaching the camera just now.
|
|
request<{ success: boolean; error?: string; resolution?: string; coalesced?: boolean }>(
|
|
`/printers/${printerId}/camera/external/test?url=${encodeURIComponent(url)}&camera_type=${encodeURIComponent(cameraType)}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Print Control
|
|
stopPrint: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/stop`, {
|
|
method: 'POST',
|
|
}),
|
|
pausePrint: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/pause`, {
|
|
method: 'POST',
|
|
}),
|
|
resumePrint: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/print/resume`, {
|
|
method: 'POST',
|
|
}),
|
|
clearPlate: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/clear-plate`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Get current print user (for reprint tracking - Issue #206)
|
|
getCurrentPrintUser: (printerId: number) =>
|
|
request<{ user_id?: number; username?: string }>(`/printers/${printerId}/current-print-user`),
|
|
|
|
// Print Speed Control
|
|
setPrintSpeed: (printerId: number, mode: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/print-speed?mode=${mode}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
setNozzleTemperature: (printerId: number, target: number, nozzle: number = 0) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/temperature/nozzle?target=${target}&nozzle=${nozzle}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
setBedTemperature: (printerId: number, target: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/temperature/bed?target=${target}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
setChamberTemperature: (printerId: number, target: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/temperature/chamber?target=${target}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
setFanSpeed: (printerId: number, fan: 'part' | 'aux' | 'aux2' | 'chamber', speed: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/fan-speed?fan=${fan}&speed=${speed}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
selectExtruder: (printerId: number, extruder: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/select-extruder?extruder=${extruder}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
setAirductMode: (printerId: number, mode: 'cooling' | 'heating') =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/airduct-mode?mode=${mode}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Bed (Z-axis) jog
|
|
bedJog: (printerId: number, distance: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/bed-jog?distance=${distance}`,
|
|
{ method: 'POST' }
|
|
),
|
|
xyJog: (printerId: number, x: number, y: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/xy-jog?x=${x}&y=${y}`,
|
|
{ method: 'POST' }
|
|
),
|
|
extruderJog: (printerId: number, distance: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/extruder-jog?distance=${distance}`,
|
|
{ method: 'POST' }
|
|
),
|
|
homeAxes: (printerId: number, axes: 'z' | 'xy' | 'all' = 'z') =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/home-axes?axes=${axes}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Chamber Light Control
|
|
setChamberLight: (printerId: number, on: boolean) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// AMS Drying Control
|
|
startDrying: (printerId: number, amsId: number, temp: number, duration: number, filament: string = '', rotateTray: boolean = false) =>
|
|
request<{ status: string; ams_id: number; temp: number; duration: number }>(
|
|
`/printers/${printerId}/drying/start?ams_id=${amsId}&temp=${temp}&duration=${duration}&filament=${encodeURIComponent(filament)}&rotate_tray=${rotateTray}`,
|
|
{ method: 'POST' }
|
|
),
|
|
stopDrying: (printerId: number, amsId: number) =>
|
|
request<{ status: string; ams_id: number }>(
|
|
`/printers/${printerId}/drying/stop?ams_id=${amsId}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Scheduled (delayed) drying runs (#2638)
|
|
createScheduledDrying: (data: {
|
|
printer_id: number;
|
|
ams_id: number;
|
|
temp: number;
|
|
duration_hours: number;
|
|
filament?: string;
|
|
rotate_tray?: boolean;
|
|
start_after: string | null;
|
|
}) =>
|
|
request<ScheduledDrying>('/scheduled-dryings', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
// Omit printerId for the whole fleet in one request; the printer cards share
|
|
// that single query rather than each polling for its own id.
|
|
listScheduledDryings: (printerId?: number) =>
|
|
request<ScheduledDrying[]>(
|
|
printerId === undefined ? '/scheduled-dryings' : `/scheduled-dryings?printer_id=${printerId}`
|
|
),
|
|
cancelScheduledDrying: (id: number) =>
|
|
request<{ status: string; id: number }>(`/scheduled-dryings/${id}`, { method: 'DELETE' }),
|
|
|
|
// AMS Filament Backup (auto-switch to a backup spool when one runs out)
|
|
setAmsFilamentBackup: (printerId: number, enabled: boolean) =>
|
|
request<{ success: boolean; ams_filament_backup: boolean }>(
|
|
`/printers/${printerId}/ams-backup?enabled=${enabled}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Per-globalTrayId remaining grams for this printer's inventory-bound slots
|
|
// (#1766). Drives the client-side "Prefer Lowest Remaining Filament" sort
|
|
// when computing the AMS mapping; mirrors backend `_build_inventory_remain_overrides`
|
|
// so internal and Spoolman modes both work uniformly.
|
|
getInventoryRemain: (printerId: number) =>
|
|
request<InventoryRemainResponse>(
|
|
`/printers/${printerId}/inventory-remain`,
|
|
),
|
|
|
|
// Skip Objects
|
|
getPrintableObjects: (printerId: number) =>
|
|
request<{
|
|
objects: Array<{ id: number; name: string; x: number | null; y: number | null; skipped: boolean }>;
|
|
total: number;
|
|
skipped_count: number;
|
|
is_printing: boolean;
|
|
bbox_all: [number, number, number, number] | null;
|
|
}>(`/printers/${printerId}/print/objects`),
|
|
|
|
skipObjects: (printerId: number, objectIds: number[]) =>
|
|
request<{ success: boolean; message: string; skipped_objects: number[] }>(
|
|
`/printers/${printerId}/print/skip-objects`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(objectIds),
|
|
}
|
|
),
|
|
|
|
// HMS Errors
|
|
clearHMSErrors: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/clear`, { method: 'POST' }),
|
|
executeHMSAction: (printerId: number, data: HMSActionBody) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/hms/execute-action`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// AMS Control
|
|
refreshAmsSlot: (printerId: number, amsId: number, slotId: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/${amsId}/slot/${slotId}/refresh`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Load filament from a tray. trayId: 0-15 for AMS (amsId*4+slotId), 254 for external spool.
|
|
// extruderId (0 = right, 1 = left) names the hotend to feed. Pass it only on a
|
|
// printer with a Filament Track Switch — there the AMS is bound to a switch
|
|
// inlet rather than a hotend, so the firmware cannot work the target out and
|
|
// drops the command. Omit it everywhere else, as BambuStudio does.
|
|
loadAmsTray: (printerId: number, trayId: number, extruderId?: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/load?tray_id=${trayId}` +
|
|
(extruderId !== undefined ? `&extruder_id=${extruderId}` : ''),
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Unload filament. trayId names the slot to unload, which is what tells a
|
|
// dual-nozzle printer which of its two hotends to act on; omit it to unload
|
|
// whatever the printer's single tray_now field names.
|
|
unloadAms: (printerId: number, trayId?: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/unload` + (trayId !== undefined ? `?tray_id=${trayId}` : ''),
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// MQTT Debug Logging
|
|
enableMQTTLogging: (printerId: number) =>
|
|
request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/enable`, {
|
|
method: 'POST',
|
|
}),
|
|
disableMQTTLogging: (printerId: number) =>
|
|
request<{ logging_enabled: boolean }>(`/printers/${printerId}/logging/disable`, {
|
|
method: 'POST',
|
|
}),
|
|
getMQTTLogs: (printerId: number) =>
|
|
request<MQTTLogsResponse>(`/printers/${printerId}/logging`),
|
|
clearMQTTLogs: (printerId: number) =>
|
|
request<{ status: string }>(`/printers/${printerId}/logging`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// Printer File Manager
|
|
getPrinterFiles: (printerId: number, path = '/') =>
|
|
request<{
|
|
path: string;
|
|
files: Array<{
|
|
name: string;
|
|
is_directory: boolean;
|
|
size: number;
|
|
path: string;
|
|
mtime?: string;
|
|
}>;
|
|
warnings: Array<'printer_unavailable'>;
|
|
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
|
|
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
|
|
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
|
|
getPrinterFileGcodeUrl: (printerId: number, path: string) =>
|
|
`${API_BASE}/printers/${printerId}/files/gcode?path=${encodeURIComponent(path)}`,
|
|
getPrinterFilePlates: (printerId: number, path: string) =>
|
|
request<{
|
|
printer_id: number;
|
|
path: string;
|
|
filename: string;
|
|
plates: Array<{
|
|
index: number;
|
|
name: string | null;
|
|
objects: string[];
|
|
has_thumbnail: boolean;
|
|
thumbnail_url: string | null;
|
|
print_time_seconds: number | null;
|
|
filament_used_grams: number | null;
|
|
filaments: Array<{
|
|
slot_id: number;
|
|
type: string;
|
|
color: string;
|
|
used_grams: number;
|
|
used_meters: number;
|
|
}>;
|
|
}>;
|
|
is_multi_plate: boolean;
|
|
}>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
|
|
getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
|
|
withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
|
|
downloadPrinterFilesAsZip: async (
|
|
printerId: number,
|
|
paths: string[],
|
|
sizes: Record<string, number>,
|
|
filename = 'printer-files.zip',
|
|
asZip = true,
|
|
signal?: AbortSignal,
|
|
onProgress?: (completed: number, total: number) => void,
|
|
): Promise<{ requested: number; successful: number; failed: number }> => {
|
|
type JobStatus = {
|
|
job_id: string;
|
|
state: 'queued' | 'preparing' | 'ready' | 'failed' | 'cancelled';
|
|
requested: number;
|
|
successful: number;
|
|
failed: number;
|
|
token: string | null;
|
|
message: string | null;
|
|
};
|
|
let jobId: string | null = null;
|
|
try {
|
|
if (signal?.aborted) throw new DOMException('Download cancelled', 'AbortError');
|
|
let status = await request<JobStatus>(`/printers/${printerId}/files/download-job`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ paths, sizes, filename, as_zip: asZip }),
|
|
signal,
|
|
});
|
|
jobId = status.job_id;
|
|
let polls = 0;
|
|
while (status.state === 'queued' || status.state === 'preparing') {
|
|
onProgress?.(status.successful + status.failed, status.requested);
|
|
// Small selections finish in the first seconds, so poll quickly there.
|
|
// A large one runs for up to half an hour, where half-second polling is
|
|
// thousands of requests that each re-check the caller's credentials.
|
|
const delay = polls < 10 ? 500 : 2000;
|
|
polls += 1;
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
reject(new DOMException('Download cancelled', 'AbortError'));
|
|
return;
|
|
}
|
|
const onAbort = () => {
|
|
window.clearTimeout(timer);
|
|
reject(new DOMException('Download cancelled', 'AbortError'));
|
|
};
|
|
const timer = window.setTimeout(() => {
|
|
signal?.removeEventListener('abort', onAbort);
|
|
resolve();
|
|
}, delay);
|
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
});
|
|
status = await request<JobStatus>(`/printers/${printerId}/files/download-jobs/${jobId}`, { signal });
|
|
}
|
|
onProgress?.(status.successful + status.failed, status.requested);
|
|
if (status.state !== 'ready' || !status.token) {
|
|
throw new Error(status.message || (status.state === 'cancelled'
|
|
? 'Download cancelled'
|
|
: 'Printer download preparation failed'));
|
|
}
|
|
const link = document.createElement('a');
|
|
link.href = `${API_BASE}/printers/${printerId}/files/dl/${encodeURIComponent(status.token)}/${encodeURIComponent(filename)}`;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
return {
|
|
requested: status.requested,
|
|
successful: status.successful,
|
|
failed: status.failed,
|
|
};
|
|
} catch (error) {
|
|
if (jobId) {
|
|
await request(`/printers/${printerId}/files/download-jobs/${jobId}`, { method: 'DELETE' }).catch(() => undefined);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
deletePrinterFile: (printerId: number, path: string) =>
|
|
request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
getPrinterStorage: (printerId: number) =>
|
|
request<{ used_bytes: number | null; free_bytes: number | null }>(`/printers/${printerId}/storage`),
|
|
|
|
// Archives
|
|
getArchives: (printerId?: number, projectId?: number, limit = 10000, offset = 0, dateFrom?: string, dateTo?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (printerId) params.set('printer_id', String(printerId));
|
|
if (projectId) params.set('project_id', String(projectId));
|
|
params.set('limit', String(limit));
|
|
params.set('offset', String(offset));
|
|
if (dateFrom) params.set('date_from', dateFrom);
|
|
if (dateTo) params.set('date_to', dateTo);
|
|
return request<Archive[]>(`/archives/?${params}`);
|
|
},
|
|
getArchivesSlim: (dateFrom?: string, dateTo?: string, createdById?: number) => {
|
|
const params = new URLSearchParams();
|
|
if (dateFrom) params.set('date_from', dateFrom);
|
|
if (dateTo) params.set('date_to', dateTo);
|
|
if (createdById !== undefined) params.set('created_by_id', String(createdById));
|
|
const qs = params.toString();
|
|
return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
|
|
},
|
|
getArchive: (id: number) => request<Archive>(`/archives/${id}`),
|
|
getArchiveRuns: (id: number) => request<PrintLogResponse>(`/archives/${id}/runs`),
|
|
/**
|
|
* Pre-flight for the delete-confirm modal (#1734). Returns the number of
|
|
* related queue items that will be removed along with the archive AND how
|
|
* many are currently printing (server 409s on delete if > 0).
|
|
*/
|
|
getArchiveDeleteImpact: (id: number) =>
|
|
request<{ related_queue_items: number; currently_printing: number }>(
|
|
`/archives/${id}/delete-impact`
|
|
),
|
|
searchArchives: (query: string, options?: {
|
|
printerId?: number;
|
|
projectId?: number;
|
|
status?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
}) => {
|
|
const params = new URLSearchParams();
|
|
params.set('q', query);
|
|
if (options?.printerId) params.set('printer_id', String(options.printerId));
|
|
if (options?.projectId) params.set('project_id', String(options.projectId));
|
|
if (options?.status) params.set('status', options.status);
|
|
if (options?.limit) params.set('limit', String(options.limit));
|
|
if (options?.offset) params.set('offset', String(options.offset));
|
|
return request<Archive[]>(`/archives/search?${params}`);
|
|
},
|
|
rebuildSearchIndex: () => request<{ message: string }>('/archives/search/rebuild-index', { method: 'POST' }),
|
|
getNo3MFWarning: () =>
|
|
request<{
|
|
has_fallback: boolean;
|
|
reason: 'internal_storage' | 'no_external_storage' | 'internal_history' | null;
|
|
}>(
|
|
'/archives/no-3mf-warning',
|
|
),
|
|
updateArchive: (id: number, data: {
|
|
printer_id?: number | null;
|
|
project_id?: number | null;
|
|
print_name?: string;
|
|
is_favorite?: boolean;
|
|
tags?: string;
|
|
notes?: string;
|
|
cost?: number;
|
|
failure_reason?: string | null;
|
|
status?: string;
|
|
quantity?: number;
|
|
external_url?: string | null;
|
|
filament_used_grams?: number | null;
|
|
}) =>
|
|
request<Archive>(`/archives/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
toggleFavorite: (id: number) =>
|
|
request<Archive>(`/archives/${id}/favorite`, { method: 'POST' }),
|
|
// Soft-deletes by default (#1343): files removed from disk, row hidden
|
|
// from listings, but its filament / time / cost / energy contribution
|
|
// stays in Quick Stats. Pass purgeStats=true to hard-delete and drop the
|
|
// row from statistics too.
|
|
deleteArchive: (id: number, purgeStats: boolean = false) =>
|
|
request<void>(`/archives/${id}${purgeStats ? '?purge_stats=true' : ''}`, { method: 'DELETE' }),
|
|
|
|
// ========== Archive auto-purge (#1008 follow-up) ==========
|
|
previewArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
|
|
request<ArchivePurgePreview>(
|
|
`/archives/purge/preview?older_than_days=${olderThanDays}&purge_stats=${purgeStats}`,
|
|
),
|
|
// #1390: purgeStats=false (default) soft-deletes each old archive — Quick Stats
|
|
// preserved, files removed from disk, row hidden via deleted_at. true matches
|
|
// the single-archive delete's `?purge_stats=true` semantics (hard-deletes the
|
|
// linked PrintLogEntry rows so the contribution drops from /stats too).
|
|
executeArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
|
|
request<{ deleted: number; purge_stats: boolean }>('/archives/purge', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ older_than_days: olderThanDays, purge_stats: purgeStats }),
|
|
}),
|
|
getArchivePurgeSettings: () =>
|
|
request<ArchivePurgeSettings>('/archives/purge/settings'),
|
|
updateArchivePurgeSettings: (body: ArchivePurgeSettings) =>
|
|
request<ArchivePurgeSettings>('/archives/purge/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
getArchiveStats: (options?: { dateFrom?: string; dateTo?: string; createdById?: number }) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.dateFrom) params.set('date_from', options.dateFrom);
|
|
if (options?.dateTo) params.set('date_to', options.dateTo);
|
|
if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
|
|
const qs = params.toString();
|
|
return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
|
|
},
|
|
// Tag management
|
|
getTags: () => request<TagInfo[]>('/archives/tags'),
|
|
renameTag: (oldName: string, newName: string) =>
|
|
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(oldName)}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ new_name: newName }),
|
|
}),
|
|
deleteTag: (name: string) =>
|
|
request<{ affected: number }>(`/archives/tags/${encodeURIComponent(name)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
recalculateCosts: () =>
|
|
request<{ message: string; updated: number }>('/archives/recalculate-costs', { method: 'POST' }),
|
|
getFailureAnalysis: (options?: { days?: number; dateFrom?: string; dateTo?: string; printerId?: number; projectId?: number; createdById?: number }) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.days) params.set('days', String(options.days));
|
|
if (options?.dateFrom) params.set('date_from', options.dateFrom);
|
|
if (options?.dateTo) params.set('date_to', options.dateTo);
|
|
if (options?.printerId) params.set('printer_id', String(options.printerId));
|
|
if (options?.projectId) params.set('project_id', String(options.projectId));
|
|
if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
|
|
const qs = params.toString();
|
|
return request<FailureAnalysis>(`/archives/analysis/failures${qs ? `?${qs}` : ''}`);
|
|
},
|
|
compareArchives: (archiveIds: number[]) =>
|
|
request<ArchiveComparison>(`/archives/compare?archive_ids=${archiveIds.join(',')}`),
|
|
findSimilarArchives: (archiveId: number, limit = 10) =>
|
|
request<SimilarArchive[]>(`/archives/${archiveId}/similar?limit=${limit}`),
|
|
exportArchives: async (options?: {
|
|
format?: 'csv' | 'xlsx';
|
|
fields?: string[];
|
|
printerId?: number;
|
|
projectId?: number;
|
|
status?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
search?: string;
|
|
}): Promise<{ blob: Blob; filename: string }> => {
|
|
const params = new URLSearchParams();
|
|
if (options?.format) params.set('format', options.format);
|
|
if (options?.fields) params.set('fields', options.fields.join(','));
|
|
if (options?.printerId) params.set('printer_id', String(options.printerId));
|
|
if (options?.projectId) params.set('project_id', String(options.projectId));
|
|
if (options?.status) params.set('status', options.status);
|
|
if (options?.dateFrom) params.set('date_from', options.dateFrom);
|
|
if (options?.dateTo) params.set('date_to', options.dateTo);
|
|
if (options?.search) params.set('search', options.search);
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/export?${params}`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let filename = options?.format === 'xlsx' ? 'archives_export.xlsx' : 'archives_export.csv';
|
|
if (contentDisposition) {
|
|
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
if (match) filename = match[1];
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
},
|
|
exportStats: async (options?: {
|
|
format?: 'csv' | 'xlsx';
|
|
days?: number;
|
|
printerId?: number;
|
|
projectId?: number;
|
|
createdById?: number;
|
|
}): Promise<{ blob: Blob; filename: string }> => {
|
|
const params = new URLSearchParams();
|
|
if (options?.format) params.set('format', options.format);
|
|
if (options?.days) params.set('days', String(options.days));
|
|
if (options?.printerId) params.set('printer_id', String(options.printerId));
|
|
if (options?.projectId) params.set('project_id', String(options.projectId));
|
|
if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/stats/export?${params}`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let filename = options?.format === 'xlsx' ? 'stats_export.xlsx' : 'stats_export.csv';
|
|
if (contentDisposition) {
|
|
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
if (match) filename = match[1];
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
},
|
|
getArchiveDuplicates: (id: number) =>
|
|
request<{ duplicates: ArchiveDuplicate[]; count: number }>(`/archives/${id}/duplicates`),
|
|
backfillContentHashes: () =>
|
|
request<{ updated: number; errors: Array<{ id: number; error: string }> }>('/archives/backfill-hashes', {
|
|
method: 'POST',
|
|
}),
|
|
getArchiveThumbnail: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/thumbnail?v=${Date.now()}`),
|
|
getArchivePlateThumbnail: (id: number, plateIndex: number) =>
|
|
withStreamToken(`${API_BASE}/archives/${id}/plate-thumbnail/${plateIndex}`),
|
|
getArchiveDownload: (id: number) => `${API_BASE}/archives/${id}/download`,
|
|
downloadArchive: async (id: number, filename?: string): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${id}/download`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const downloadFilename = parseContentDispositionFilename(disposition) || filename || `archive_${id}.3mf`;
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = downloadFilename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
|
|
getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),
|
|
getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
|
|
downloadArchiveTimelapse: async (id: number, filename: string): Promise<void> => {
|
|
const prepared = await request<{ token: string; filename: string }>(
|
|
`/archives/${id}/media-download-token`,
|
|
{ method: 'POST' },
|
|
);
|
|
const link = document.createElement('a');
|
|
link.href = `${API_BASE}/archives/${id}/media/dl/${encodeURIComponent(prepared.token)}/${encodeURIComponent(filename)}`;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
},
|
|
getArchivePrinterMedia: (id: number) =>
|
|
request<ArchivePrinterMedia>(`/archives/${id}/printer-media`),
|
|
scanArchiveTimelapse: (id: number) =>
|
|
request<{
|
|
status: string;
|
|
message: string;
|
|
filename?: string;
|
|
available_files?: Array<{ name: string; path: string; size: number; mtime: string | null }>;
|
|
}>(`/archives/${id}/timelapse/scan`, {
|
|
method: 'POST',
|
|
}),
|
|
selectArchiveTimelapse: (id: number, filename: string) =>
|
|
request<{ status: string; message: string; filename: string }>(
|
|
`/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,
|
|
{ method: 'POST' }
|
|
),
|
|
deleteArchiveTimelapse: (id: number) =>
|
|
request<{ status: string }>(`/archives/${id}/timelapse`, {
|
|
method: 'DELETE',
|
|
}),
|
|
uploadArchiveTimelapse: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
// Timelapse Editor
|
|
getTimelapseInfo: (archiveId: number) =>
|
|
request<{
|
|
duration: number;
|
|
width: number;
|
|
height: number;
|
|
fps: number;
|
|
codec: string;
|
|
file_size: number;
|
|
has_audio: boolean;
|
|
}>(`/archives/${archiveId}/timelapse/info`),
|
|
getTimelapseThumbnails: (archiveId: number, count: number = 10) =>
|
|
request<{
|
|
thumbnails: string[];
|
|
timestamps: number[];
|
|
}>(`/archives/${archiveId}/timelapse/thumbnails?count=${count}`),
|
|
processTimelapse: async (
|
|
archiveId: number,
|
|
params: {
|
|
trimStart?: number;
|
|
trimEnd?: number;
|
|
speed?: number;
|
|
saveMode: 'replace' | 'new';
|
|
outputFilename?: string;
|
|
},
|
|
audioFile?: File
|
|
): Promise<{ status: string; output_path: string | null; message: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('trim_start', String(params.trimStart ?? 0));
|
|
if (params.trimEnd !== undefined) {
|
|
formData.append('trim_end', String(params.trimEnd));
|
|
}
|
|
formData.append('speed', String(params.speed ?? 1));
|
|
formData.append('save_mode', params.saveMode);
|
|
if (params.outputFilename) {
|
|
formData.append('output_filename', params.outputFilename);
|
|
}
|
|
if (audioFile) {
|
|
formData.append('audio', audioFile);
|
|
}
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/process`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
// Photos
|
|
getArchivePhotoUrl: (archiveId: number, filename: string) =>
|
|
withStreamToken(`${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`),
|
|
uploadArchivePhoto: async (archiveId: number, file: File): Promise<{ status: string; filename: string; photos: string[] }> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {
|
|
headers,
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
deleteArchivePhoto: (archiveId: number, filename: string) =>
|
|
request<{ status: string; photos: string[] | null }>(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
// Source 3MF (original slicer project file)
|
|
getSource3mfDownloadUrl: (archiveId: number) =>
|
|
`${API_BASE}/archives/${archiveId}/source`,
|
|
downloadSource3mf: async (archiveId: number): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const filename = parseContentDispositionFilename(disposition) || `source_${archiveId}.3mf`;
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
getSource3mfForSlicer: (archiveId: number, filename: string) => {
|
|
// Sanitize: slicers url_decode() the entire URL, so / \ ? # in filenames break path routing
|
|
const safe = filename.replace(/[/\\?#]/g, '_');
|
|
return `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
|
|
},
|
|
createSourceSlicerToken: (archiveId: number) =>
|
|
request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
|
|
getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {
|
|
const safe = filename.replace(/[/\\?#]/g, '_');
|
|
return `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
|
|
},
|
|
uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
deleteSource3mf: (archiveId: number) =>
|
|
request<{ status: string }>(`/archives/${archiveId}/source`, {
|
|
method: 'DELETE',
|
|
}),
|
|
// F3D (Fusion 360 design file)
|
|
getF3dDownloadUrl: (archiveId: number) =>
|
|
`${API_BASE}/archives/${archiveId}/f3d`,
|
|
downloadF3d: async (archiveId: number): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const filename = parseContentDispositionFilename(disposition) || `archive_${archiveId}.f3d`;
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
uploadF3d: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/archives/${archiveId}/f3d`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
deleteF3d: (archiveId: number) =>
|
|
request<{ status: string }>(`/archives/${archiveId}/f3d`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// QR Code
|
|
getArchiveQRCodeUrl: (archiveId: number, size = 200) =>
|
|
withStreamToken(`${API_BASE}/archives/${archiveId}/qrcode?size=${size}`),
|
|
getArchiveCapabilities: (id: number) =>
|
|
request<{
|
|
has_model: boolean;
|
|
has_gcode: boolean;
|
|
has_source: boolean;
|
|
build_volume: { x: number; y: number; z: number };
|
|
filament_colors: string[];
|
|
}>(`/archives/${id}/capabilities`),
|
|
// Project Page
|
|
getArchiveProjectPage: (id: number) =>
|
|
request<{
|
|
title: string | null;
|
|
description: string | null;
|
|
designer: string | null;
|
|
designer_user_id: string | null;
|
|
license: string | null;
|
|
copyright: string | null;
|
|
creation_date: string | null;
|
|
modification_date: string | null;
|
|
origin: string | null;
|
|
profile_title: string | null;
|
|
profile_description: string | null;
|
|
profile_cover: string | null;
|
|
profile_user_id: string | null;
|
|
profile_user_name: string | null;
|
|
design_model_id: string | null;
|
|
design_profile_id: string | null;
|
|
design_region: string | null;
|
|
model_pictures: Array<{ name: string; path: string; url: string }>;
|
|
profile_pictures: Array<{ name: string; path: string; url: string }>;
|
|
thumbnails: Array<{ name: string; path: string; url: string }>;
|
|
}>(`/archives/${id}/project-page`),
|
|
updateArchiveProjectPage: (id: number, data: {
|
|
title?: string;
|
|
description?: string;
|
|
designer?: string;
|
|
license?: string;
|
|
copyright?: string;
|
|
profile_title?: string;
|
|
profile_description?: string;
|
|
}) =>
|
|
request(`/archives/${id}/project-page`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
|
|
withStreamToken(`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`),
|
|
getArchiveForSlicer: (id: number, filename: string) => {
|
|
const safe = filename.replace(/[/\\?#]/g, '_');
|
|
return `${API_BASE}/archives/${id}/file/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
|
|
},
|
|
createArchiveSlicerToken: (archiveId: number) =>
|
|
request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
|
|
getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {
|
|
const safe = filename.replace(/[/\\?#]/g, '_');
|
|
return `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
|
|
},
|
|
getArchivePlates: (archiveId: number) =>
|
|
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
|
|
getArchiveFilamentRequirements: (
|
|
archiveId: number,
|
|
plateId?: number,
|
|
requestId?: string,
|
|
/** Ask for one entry per project slot instead of only the slots this
|
|
* plate consumes. The slice modal needs it: its filament list is
|
|
* positional, so a source whose only used slot is 4 must still present
|
|
* four rows or the user's pick is bound to slot 1 (#2712). Print-time
|
|
* AMS matching must NOT set this — it wants the used-only list. */
|
|
fullSlots?: boolean,
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (plateId !== undefined) qs.set('plate_id', String(plateId));
|
|
if (requestId) qs.set('request_id', requestId);
|
|
if (fullSlots) qs.set('full_slots', 'true');
|
|
return request<{
|
|
archive_id: number;
|
|
filename: string;
|
|
plate_id: number | null;
|
|
filaments: Array<{
|
|
slot_id: number;
|
|
type: string;
|
|
color: string;
|
|
used_grams: number;
|
|
used_meters: number;
|
|
used_in_plate?: boolean;
|
|
}>;
|
|
}>(`/archives/${archiveId}/filament-requirements${qs.toString() ? `?${qs}` : ''}`);
|
|
},
|
|
uploadArchive: async (file: File, printerId?: number): Promise<Archive> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const url = printerId
|
|
? `${API_BASE}/archives/upload?printer_id=${printerId}`
|
|
: `${API_BASE}/archives/upload`;
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
uploadArchivesBulk: async (files: File[], printerId?: number): Promise<BulkUploadResult> => {
|
|
const formData = new FormData();
|
|
files.forEach((file) => formData.append('files', file));
|
|
const url = printerId
|
|
? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`
|
|
: `${API_BASE}/archives/upload-bulk`;
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
|
|
// Print Log
|
|
getPrintLog: (params?: {
|
|
search?: string;
|
|
printerId?: number;
|
|
username?: string;
|
|
status?: string;
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
// Sorting is server-side because paging is: ordering only the rows the
|
|
// client holds would sort one page, not the log (#2636).
|
|
sortBy?: string;
|
|
sortDir?: 'asc' | 'desc';
|
|
}) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.search) searchParams.set('search', params.search);
|
|
if (params?.printerId) searchParams.set('printer_id', String(params.printerId));
|
|
if (params?.username) searchParams.set('created_by_username', params.username);
|
|
if (params?.status) searchParams.set('status', params.status);
|
|
if (params?.dateFrom) searchParams.set('date_from', params.dateFrom);
|
|
if (params?.dateTo) searchParams.set('date_to', params.dateTo);
|
|
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
|
|
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
|
|
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
|
|
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
|
|
},
|
|
getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
|
|
clearPrintLog: () =>
|
|
request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
|
|
deletePrintLogEntry: (id: number) =>
|
|
request<{ status: string; id: number }>(`/print-log/${id}`, { method: 'DELETE' }),
|
|
// Edit failure_reason / status on a single Print Log row (#1687 part 4).
|
|
// Distinct from updateArchive: archives describe the model, log entries
|
|
// describe a single print event. Orphan entries (no archive_id) have no
|
|
// archive to reach through and this is the only path to classify them.
|
|
updatePrintLogEntry: (
|
|
id: number,
|
|
body: { failure_reason?: string | null; status?: string },
|
|
) =>
|
|
request<PrintLogEntry>(`/print-log/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
// Settings
|
|
getSettings: () => request<AppSettings>('/settings/'),
|
|
getDefaultSidebarOrder: () => request<{ default_sidebar_order: string }>('/settings/default-sidebar-order'),
|
|
// Public subset of settings for UI rendering — no settings:read required.
|
|
// Used by pages whose users may not have SETTINGS_READ (e.g. operators with
|
|
// only printers:clear_plate). Keep in sync with _UI_PREFERENCE_FIELDS in
|
|
// backend/app/api/routes/settings.py.
|
|
getUiPreferences: () =>
|
|
request<{
|
|
require_plate_clear?: boolean;
|
|
check_printer_firmware?: boolean;
|
|
camera_view_mode?: 'window' | 'embedded';
|
|
time_format?: 'system' | '12h' | '24h';
|
|
date_format?: string;
|
|
drying_presets?: string;
|
|
ams_humidity_thresholds?: string;
|
|
ams_humidity_good?: number;
|
|
ams_humidity_fair?: number;
|
|
ams_temp_good?: number;
|
|
ams_temp_fair?: number;
|
|
bed_cooled_threshold?: number;
|
|
nozzle_temp_presets?: string;
|
|
bed_temp_presets?: string;
|
|
chamber_temp_presets?: string;
|
|
fan_speed_presets?: string;
|
|
}>('/settings/ui-preferences'),
|
|
updateSettings: (data: AppSettingsUpdate) =>
|
|
request<AppSettings>('/settings/', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getMQTTStatus: () => request<MQTTStatus>('/settings/mqtt/status'),
|
|
resetSettings: () =>
|
|
request<AppSettings>('/settings/reset', { method: 'POST' }),
|
|
exportBackup: async (): Promise<{ blob: Blob; filename: string }> => {
|
|
// New simplified backup - complete database + all files
|
|
const url = `${API_BASE}/settings/backup`;
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(url, { headers });
|
|
|
|
// Check for errors
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(errorText || `Backup failed with status ${response.status}`);
|
|
}
|
|
|
|
// Get filename from Content-Disposition header
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
let filename = 'bambuddy-backup.zip';
|
|
if (contentDisposition) {
|
|
const match = contentDisposition.match(/filename=([^;]+)/);
|
|
if (match) filename = match[1].trim().replace(/^"(.*)"$/, '$1');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
},
|
|
importBackup: async (file: File) => {
|
|
// New simplified restore - replaces database + all directories
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const url = `${API_BASE}/settings/restore`;
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
return response.json() as Promise<{
|
|
success: boolean;
|
|
message: string;
|
|
}>;
|
|
},
|
|
checkFfmpeg: () =>
|
|
request<{ installed: boolean; path: string | null }>('/settings/check-ffmpeg'),
|
|
getNetworkInterfaces: () =>
|
|
request<{ interfaces: NetworkInterface[] }>('/settings/network-interfaces'),
|
|
|
|
// Cloud
|
|
getCloudStatus: () => request<CloudAuthStatus>('/cloud/status'),
|
|
cloudLogin: (email: string, password: string, region = 'global') =>
|
|
request<CloudLoginResponse>('/cloud/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password, region }),
|
|
}),
|
|
cloudVerify: (email: string, code: string, tfaKey?: string, region: string = 'global') =>
|
|
request<CloudLoginResponse>('/cloud/verify', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, code, tfa_key: tfaKey, region }),
|
|
}),
|
|
cloudSetToken: (access_token: string, region: string = 'global') =>
|
|
request<CloudAuthStatus>('/cloud/token', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ access_token, region }),
|
|
}),
|
|
cloudLogout: () =>
|
|
request<{ success: boolean }>('/cloud/logout', { method: 'POST' }),
|
|
|
|
// Orca Cloud — RFC 8628 device pairing. deviceStart() returns a short
|
|
// user_code + verification link; the user approves it in their Orca Cloud
|
|
// settings while the frontend polls devicePoll() every `interval` seconds
|
|
// until the status flips to 'complete' (or a terminal deny/expire).
|
|
orcaCloudDeviceStart: () =>
|
|
request<OrcaDeviceStartResponse>('/orca-cloud/device/start', {
|
|
method: 'POST',
|
|
}),
|
|
orcaCloudDevicePoll: () =>
|
|
request<OrcaDevicePollResponse>('/orca-cloud/device/poll', {
|
|
method: 'POST',
|
|
}),
|
|
orcaCloudStatus: () =>
|
|
request<OrcaAuthStatusResponse>('/orca-cloud/status'),
|
|
orcaCloudLogout: () =>
|
|
request<{ success: boolean }>('/orca-cloud/logout', { method: 'POST' }),
|
|
orcaCloudListProfiles: () =>
|
|
request<OrcaProfileListResponse>('/orca-cloud/profiles'),
|
|
orcaCloudGetProfile: (id: string) =>
|
|
request<OrcaProfileDetail>(`/orca-cloud/profiles/${id}`),
|
|
getCloudSettings: (version = '02.04.00.70') =>
|
|
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
|
|
getBuiltinFilaments: () =>
|
|
request<BuiltinFilament[]>('/cloud/builtin-filaments'),
|
|
getFilamentIdMap: () =>
|
|
request<Record<string, string>>('/cloud/filament-id-map'),
|
|
|
|
/** Material-disambiguated hex→name lookup. Same hex can map to different
|
|
* catalog names depending on material (e.g. #000000 is "Charcoal" in PLA
|
|
* Matte but "Black" in PLA Basic). The flat ``/inventory/colors/map``
|
|
* collapses these to the first hit; this endpoint preserves the material
|
|
* context. Returns ``{color_name: null}`` when the hex isn't in the
|
|
* catalog at all. #1718. */
|
|
getColorByMaterial: (hex: string, material?: string) => {
|
|
const params = new URLSearchParams({ hex });
|
|
if (material) params.set('material', material);
|
|
return request<{ color_name: string | null }>(
|
|
`/inventory/colors/by-material?${params.toString()}`,
|
|
);
|
|
},
|
|
|
|
// MakerWorld URL-paste import flow.
|
|
getMakerworldStatus: () =>
|
|
request<MakerworldStatus>('/makerworld/status'),
|
|
resolveMakerworldUrl: (url: string) =>
|
|
request<MakerworldResolvedModel>('/makerworld/resolve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url }),
|
|
}),
|
|
getMakerworldRecentImports: (limit = 10) =>
|
|
request<MakerworldRecentImport[]>(`/makerworld/recent-imports?limit=${limit}`),
|
|
importMakerworldInstance: (
|
|
model_id: number,
|
|
instance_id: number | null,
|
|
profile_id?: number | null,
|
|
folder_id?: number | null,
|
|
) =>
|
|
request<MakerworldImportResponse>('/makerworld/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
model_id,
|
|
instance_id: instance_id ?? null,
|
|
profile_id: profile_id ?? null,
|
|
folder_id: folder_id ?? null,
|
|
}),
|
|
}),
|
|
getCloudSettingDetail: (settingId: string) =>
|
|
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
|
|
createCloudSetting: (data: SlicerSettingCreate) =>
|
|
request<SlicerSettingDetail>('/cloud/settings', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>
|
|
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteCloudSetting: (settingId: string) =>
|
|
request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
|
|
getCloudFields: (presetType: 'filament' | 'print' | 'process' | 'printer') =>
|
|
request<FieldDefinitionsResponse>(`/cloud/fields/${presetType}`),
|
|
getAllCloudFields: () =>
|
|
request<Record<string, FieldDefinitionsResponse>>('/cloud/fields'),
|
|
getFilamentInfo: (settingIds: string[]) =>
|
|
request<Record<string, { name: string; k: number | null }>>('/cloud/filament-info', {
|
|
method: 'POST',
|
|
body: JSON.stringify(settingIds),
|
|
}),
|
|
|
|
// Smart Plugs
|
|
getSmartPlugs: () => request<SmartPlug[]>('/smart-plugs/'),
|
|
getSmartPlug: (id: number) => request<SmartPlug>(`/smart-plugs/${id}`),
|
|
getSmartPlugByPrinter: (printerId: number) => request<SmartPlug | null>(`/smart-plugs/by-printer/${printerId}`),
|
|
getScriptPlugsByPrinter: (printerId: number) => request<SmartPlug[]>(`/smart-plugs/by-printer/${printerId}/scripts`),
|
|
createSmartPlug: (data: SmartPlugCreate) =>
|
|
request<SmartPlug>('/smart-plugs/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateSmartPlug: (id: number, data: SmartPlugUpdate) =>
|
|
request<SmartPlug>(`/smart-plugs/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteSmartPlug: (id: number) =>
|
|
request<void>(`/smart-plugs/${id}`, { method: 'DELETE' }),
|
|
controlSmartPlug: (id: number, action: 'on' | 'off' | 'toggle') =>
|
|
request<{ success: boolean; action: string }>(`/smart-plugs/${id}/control`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ action }),
|
|
}),
|
|
getSmartPlugStatus: (id: number) =>
|
|
request<SmartPlugStatus>(`/smart-plugs/${id}/status`),
|
|
testSmartPlugConnection: (ip_address: string, username?: string | null, password?: string | null) =>
|
|
request<SmartPlugTestResult>('/smart-plugs/test-connection', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ip_address, username, password }),
|
|
}),
|
|
|
|
// Tasmota Discovery (auto-detects network)
|
|
startTasmotaScan: () =>
|
|
request<TasmotaScanStatus>('/smart-plugs/discover/scan', { method: 'POST' }),
|
|
getTasmotaScanStatus: () =>
|
|
request<TasmotaScanStatus>('/smart-plugs/discover/status'),
|
|
stopTasmotaScan: () =>
|
|
request<TasmotaScanStatus>('/smart-plugs/discover/stop', { method: 'POST' }),
|
|
getDiscoveredTasmotaDevices: () =>
|
|
request<DiscoveredTasmotaDevice[]>('/smart-plugs/discover/devices'),
|
|
|
|
// Home Assistant Integration
|
|
testHAConnection: (url: string, token: string) =>
|
|
request<HATestConnectionResult>('/smart-plugs/ha/test-connection', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, token }),
|
|
}),
|
|
getHAEntities: (search?: string) => {
|
|
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
|
return request<HAEntity[]>(`/smart-plugs/ha/entities${params}`);
|
|
},
|
|
getHASensorEntities: () =>
|
|
request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
|
|
|
|
// Home Assistant sensors bound to a printer (#1148, #448)
|
|
getHASensors: (printerId?: number) =>
|
|
request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
|
|
getHASensorReadings: (printerId: number) =>
|
|
request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
|
|
getBindableHAEntities: (search?: string) => {
|
|
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
|
return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
|
|
},
|
|
createHASensor: (data: PrinterHASensorCreate) =>
|
|
request<PrinterHASensor>('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateHASensor: (id: number, data: PrinterHASensorUpdate) =>
|
|
request<PrinterHASensor>(`/ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteHASensor: (id: number) =>
|
|
request<{ message: string }>(`/ha-sensors/${id}`, { method: 'DELETE' }),
|
|
|
|
getLocationHASensors: (locationId?: number) =>
|
|
request<LocationHASensor[]>(`/location-ha-sensors/${locationId ? `?location_id=${locationId}` : ''}`),
|
|
getLocationHASensorReadings: (locationId: number, showOnCard = true) =>
|
|
request<LocationHASensorReading[]>(
|
|
`/location-ha-sensors/by-location/${locationId}/readings?show_on_card=${showOnCard}`
|
|
),
|
|
getBindableLocationHAEntities: (search?: string) => {
|
|
const params = search ? `?search=${encodeURIComponent(search)}` : '';
|
|
return request<HADisplayEntity[]>(`/location-ha-sensors/entities${params}`);
|
|
},
|
|
createLocationHASensor: (data: LocationHASensorCreate) =>
|
|
request<LocationHASensor>('/location-ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateLocationHASensor: (id: number, data: LocationHASensorUpdate) =>
|
|
request<LocationHASensor>(`/location-ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteLocationHASensor: (id: number) =>
|
|
request<{ message: string }>(`/location-ha-sensors/${id}`, { method: 'DELETE' }),
|
|
|
|
// REST smart plug
|
|
testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
|
|
request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url, method, headers }),
|
|
}),
|
|
|
|
// Print Queue
|
|
getQueue: (printerId?: number, status?: string, targetModel?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (printerId) params.set('printer_id', String(printerId));
|
|
if (status) params.set('status', status);
|
|
if (targetModel) params.set('target_model', targetModel);
|
|
return request<PrintQueueItem[]>(`/queue/?${params}`);
|
|
},
|
|
getQueueItem: (id: number) => request<PrintQueueItem>(`/queue/${id}`),
|
|
addToQueue: (data: PrintQueueItemCreate) =>
|
|
request<PrintQueueItem>('/queue/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateQueueItem: (id: number, data: PrintQueueItemUpdate) =>
|
|
request<PrintQueueItem>(`/queue/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
/** `deleted` is false when the item was kept as cancelled instead: it is the
|
|
* last run a batch order could re-queue its plate from (#2960). */
|
|
removeFromQueue: (id: number) =>
|
|
request<{ message: string; deleted: boolean }>(`/queue/${id}`, { method: 'DELETE' }),
|
|
reorderQueue: (items: { id: number; position: number }[]) =>
|
|
request<{ message: string }>('/queue/reorder', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ items }),
|
|
}),
|
|
cancelQueueItem: (id: number) =>
|
|
request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
|
|
stopQueueItem: (id: number) =>
|
|
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
|
|
/**
|
|
* Start a staged queue item. The backend re-checks live filament deficit
|
|
* for the assigned spool and, when short, returns 409 with a structured
|
|
* payload so the caller can confirm and retry. Pass `skipFilamentCheck`
|
|
* after the user confirms "Print Anyway" (#1496).
|
|
*/
|
|
startQueueItem: (id: number, opts?: { skipFilamentCheck?: boolean }) => {
|
|
const qs = opts?.skipFilamentCheck ? '?skip_filament_check=true' : '';
|
|
return request<PrintQueueItem>(`/queue/${id}/start${qs}`, { method: 'POST' });
|
|
},
|
|
/**
|
|
* Clear the `require_previous_success` gate for a printer after the user
|
|
* resolves the failure. Acknowledges any failed/aborted predecessors and
|
|
* restores skipped items whose error_message matches the gate string
|
|
* back to pending. Returns counts so the UI can render a precise toast.
|
|
*/
|
|
resumeQueueAfterFailure: (printerId: number) =>
|
|
request<{ acknowledged: number; restored: number }>(
|
|
`/queue/printer/${printerId}/resume`,
|
|
{ method: 'POST' },
|
|
),
|
|
bulkUpdateQueue: (data: PrintQueueBulkUpdate) =>
|
|
request<PrintQueueBulkUpdateResponse>('/queue/bulk', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
// Batches
|
|
getBatches: (status?: string) => {
|
|
const params = status ? `?status=${status}` : '';
|
|
return request<PrintBatch[]>(`/queue/batches${params}`);
|
|
},
|
|
getBatch: (id: number) => request<PrintBatch>(`/queue/batches/${id}`),
|
|
cancelBatch: (id: number) =>
|
|
request<{ message: string }>(`/queue/batches/${id}`, { method: 'DELETE' }),
|
|
createBatch: (data: PrintBatchCreate) =>
|
|
request<PrintBatch>('/queue/batches', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateBatch: (id: number, data: PrintBatchUpdate) =>
|
|
request<PrintBatch>(`/queue/batches/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
dispatchBatch: (id: number, data: PrintBatchDispatchRequest = {}) =>
|
|
request<PrintBatch>(`/queue/batches/${id}/dispatch`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
ungroupBatch: (id: number) =>
|
|
request<{ ungrouped_count: number; message: string }>(
|
|
`/queue/batches/${id}/ungroup`,
|
|
{ method: 'POST' },
|
|
),
|
|
|
|
// K-Profiles
|
|
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
|
|
request<KProfilesResponse>(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),
|
|
setKProfile: (printerId: number, profile: KProfileCreate) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(profile),
|
|
}),
|
|
deleteKProfile: (printerId: number, profile: KProfileDelete) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify(profile),
|
|
}),
|
|
setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(profiles),
|
|
}),
|
|
|
|
// Finance
|
|
getMyBalance: () => request<WalletBalance>('/finance/me/balance'),
|
|
getMyTransactions: (limit = 50, offset = 0) =>
|
|
request<WalletTransactionListResponse>(`/finance/me/transactions?limit=${limit}&offset=${offset}`),
|
|
getAllTransactions: (limit = 50, offset = 0, userId?: number) => {
|
|
const params = new URLSearchParams();
|
|
params.set('limit', String(limit));
|
|
params.set('offset', String(offset));
|
|
if (userId !== undefined) params.set('user_id', String(userId));
|
|
return request<WalletTransactionListResponse>(`/finance/transactions?${params.toString()}`);
|
|
},
|
|
deleteTransaction: (transactionId: number) =>
|
|
request<{ status: string }>(`/finance/transactions/${transactionId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
editTransaction: (transactionId: number, data: TransactionEditRequest) =>
|
|
request<WalletTransaction>(`/finance/transactions/${transactionId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
createManualPrint: (data: ManualPrintRequest) =>
|
|
request<WalletTransaction>('/finance/transactions/manual', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getMyCostCenters: () => request<CostCenterSummary[]>('/finance/cost-centers/mine'),
|
|
listCostCenters: (includeInactive = false) =>
|
|
request<CostCenterSummary[]>(`/finance/cost-centers?include_inactive=${includeInactive ? 'true' : 'false'}`),
|
|
createCostCenter: (data: CostCenterCreateRequest) =>
|
|
request<CostCenterSummary>('/finance/cost-centers', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateCostCenter: (costCenterId: number, data: CostCenterUpdateRequest) =>
|
|
request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateCostCenterBudgets: (costCenterId: number, data: CostCenterBudgetUpdateRequest) =>
|
|
request<CostCenterSummary>(`/finance/cost-centers/${costCenterId}/budgets`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteCostCenter: (costCenterId: number) =>
|
|
request<{ status: string }>(`/finance/cost-centers/${costCenterId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
getCostCenter: (costCenterId: number) =>
|
|
request<CostCenterDetail>(`/finance/cost-centers/${costCenterId}`),
|
|
upsertCostCenterMember: (costCenterId: number, data: CostCenterMemberRequest) =>
|
|
request<CostCenterMemberResponse>(`/finance/cost-centers/${costCenterId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
removeCostCenterMember: (costCenterId: number, userId: number) =>
|
|
request<{ status: string }>(`/finance/cost-centers/${costCenterId}/members/${userId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
depositUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
|
|
request<WalletAdjustmentResponse>(`/finance/users/${userId}/deposit`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
withdrawUserBalance: (userId: number, data: WalletAdjustmentRequest) =>
|
|
request<WalletAdjustmentResponse>(`/finance/users/${userId}/withdraw`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
rebuildBalanceLedger: () =>
|
|
request<{ status: string }>('/finance/rebuild-balance-ledger', {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// K-Profile Notes (stored locally, not on printer)
|
|
getKProfileNotes: (printerId: number) =>
|
|
request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
|
|
setKProfileNote: (printerId: number, settingId: string, note: string) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ setting_id: settingId, note }),
|
|
}),
|
|
deleteKProfileNote: (printerId: number, settingId: string) =>
|
|
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// Slot Preset Mappings
|
|
getSlotPresets: (printerId: number) =>
|
|
request<Record<number, SlotPresetMapping>>(`/printers/${printerId}/slot-presets`),
|
|
getSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
|
request<SlotPresetMapping | null>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),
|
|
saveSlotPreset: (printerId: number, amsId: number, trayId: number, presetId: string, presetName: string, presetSource = 'cloud') =>
|
|
request<SlotPresetMapping>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&preset_name=${encodeURIComponent(presetName)}&preset_source=${encodeURIComponent(presetSource)}`, {
|
|
method: 'PUT',
|
|
}),
|
|
deleteSlotPreset: (printerId: number, amsId: number, trayId: number) =>
|
|
request<{ success: boolean }>(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// AMS Labels (user-defined friendly names)
|
|
getAmsLabels: (printerId: number) =>
|
|
request<Record<number, string>>(`/printers/${printerId}/ams-labels`),
|
|
saveAmsLabel: (printerId: number, amsId: number, label: string, amsSerial = '') =>
|
|
request<{ ams_id: number; label: string }>(
|
|
`/printers/${printerId}/ams-labels/${amsId}`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify({ label, ams_serial: amsSerial }),
|
|
}
|
|
),
|
|
deleteAmsLabel: (printerId: number, amsId: number, amsSerial = '') =>
|
|
request<{ success: boolean }>(`/printers/${printerId}/ams-labels/${amsId}?ams_serial=${encodeURIComponent(amsSerial)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
configureAmsSlot: (
|
|
printerId: number,
|
|
amsId: number,
|
|
trayId: number,
|
|
config: {
|
|
tray_info_idx: string;
|
|
tray_type: string;
|
|
tray_sub_brands: string;
|
|
tray_color: string;
|
|
nozzle_temp_min: number;
|
|
nozzle_temp_max: number;
|
|
cali_idx: number;
|
|
nozzle_diameter: string;
|
|
setting_id?: string;
|
|
kprofile_filament_id?: string;
|
|
kprofile_setting_id?: string;
|
|
k_value?: number;
|
|
}
|
|
) => {
|
|
const params = new URLSearchParams({
|
|
tray_info_idx: config.tray_info_idx,
|
|
tray_type: config.tray_type,
|
|
tray_sub_brands: config.tray_sub_brands,
|
|
tray_color: config.tray_color,
|
|
nozzle_temp_min: config.nozzle_temp_min.toString(),
|
|
nozzle_temp_max: config.nozzle_temp_max.toString(),
|
|
cali_idx: config.cali_idx.toString(),
|
|
nozzle_diameter: config.nozzle_diameter,
|
|
});
|
|
if (config.setting_id) {
|
|
params.set('setting_id', config.setting_id);
|
|
}
|
|
if (config.kprofile_filament_id) {
|
|
params.set('kprofile_filament_id', config.kprofile_filament_id);
|
|
}
|
|
if (config.kprofile_setting_id) {
|
|
params.set('kprofile_setting_id', config.kprofile_setting_id);
|
|
}
|
|
if (config.k_value !== undefined && config.k_value > 0) {
|
|
params.set('k_value', config.k_value.toString());
|
|
}
|
|
return request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/slots/${amsId}/${trayId}/configure?${params}`,
|
|
{ method: 'POST' }
|
|
);
|
|
},
|
|
resetAmsSlot: (printerId: number, amsId: number, trayId: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/${amsId}/tray/${trayId}/reset`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Filament Catalog (material types with cost/temp data)
|
|
listFilaments: () => request<Filament[]>('/filament-catalog/'),
|
|
getFilament: (id: number) => request<Filament>(`/filament-catalog/${id}`),
|
|
getFilamentsByType: (type: string) => request<Filament[]>(`/filament-catalog/by-type/${type}`),
|
|
|
|
// Notification Providers
|
|
getNotificationProviders: () => request<NotificationProvider[]>('/notifications/'),
|
|
getNotificationProvider: (id: number) => request<NotificationProvider>(`/notifications/${id}`),
|
|
createNotificationProvider: (data: NotificationProviderCreate) =>
|
|
request<NotificationProvider>('/notifications/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateNotificationProvider: (id: number, data: NotificationProviderUpdate) =>
|
|
request<NotificationProvider>(`/notifications/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteNotificationProvider: (id: number) =>
|
|
request<{ message: string }>(`/notifications/${id}`, { method: 'DELETE' }),
|
|
testNotificationProvider: (id: number) =>
|
|
request<NotificationTestResponse>(`/notifications/${id}/test`, { method: 'POST' }),
|
|
testNotificationConfig: (data: NotificationTestRequest) =>
|
|
request<NotificationTestResponse>('/notifications/test-config', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
testAllNotificationProviders: () =>
|
|
request<{
|
|
tested: number;
|
|
success: number;
|
|
failed: number;
|
|
results: Array<{
|
|
provider_id: number;
|
|
provider_name: string;
|
|
provider_type: string;
|
|
success: boolean;
|
|
message: string;
|
|
}>;
|
|
}>('/notifications/test-all', { method: 'POST' }),
|
|
|
|
// Notification Templates
|
|
getNotificationTemplates: () => request<NotificationTemplate[]>('/notification-templates'),
|
|
getNotificationTemplate: (id: number) => request<NotificationTemplate>(`/notification-templates/${id}`),
|
|
updateNotificationTemplate: (id: number, data: NotificationTemplateUpdate) =>
|
|
request<NotificationTemplate>(`/notification-templates/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
resetNotificationTemplate: (id: number) =>
|
|
request<NotificationTemplate>(`/notification-templates/${id}/reset`, {
|
|
method: 'POST',
|
|
}),
|
|
getTemplateVariables: () => request<EventVariablesResponse[]>('/notification-templates/variables'),
|
|
previewTemplate: (data: TemplatePreviewRequest) =>
|
|
request<TemplatePreviewResponse>('/notification-templates/preview', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// Notification Logs
|
|
getNotificationLogs: (params?: {
|
|
limit?: number;
|
|
offset?: number;
|
|
provider_id?: number;
|
|
event_type?: string;
|
|
success?: boolean;
|
|
days?: number;
|
|
}) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));
|
|
if (params?.event_type) searchParams.set('event_type', params.event_type);
|
|
if (params?.success !== undefined) searchParams.set('success', String(params.success));
|
|
if (params?.days) searchParams.set('days', String(params.days));
|
|
return request<NotificationLogEntry[]>(`/notifications/logs?${searchParams}`);
|
|
},
|
|
getNotificationLogStats: (days = 7) =>
|
|
request<NotificationLogStats>(`/notifications/logs/stats?days=${days}`),
|
|
clearNotificationLogs: (olderThanDays = 30) =>
|
|
request<{ deleted: number; message: string }>(
|
|
`/notifications/logs?older_than_days=${olderThanDays}`,
|
|
{ method: 'DELETE' }
|
|
),
|
|
|
|
// Spoolman Integration
|
|
getSpoolmanStatus: () => request<SpoolmanStatus>('/spoolman/status'),
|
|
connectSpoolman: () =>
|
|
request<{ success: boolean; message: string }>('/spoolman/connect', {
|
|
method: 'POST',
|
|
}),
|
|
disconnectSpoolman: () =>
|
|
request<{ success: boolean; message: string }>('/spoolman/disconnect', {
|
|
method: 'POST',
|
|
}),
|
|
syncPrinterAms: (printerId: number) =>
|
|
request<SpoolmanSyncResult>(`/spoolman/sync/${printerId}`, {
|
|
method: 'POST',
|
|
}),
|
|
syncAllPrintersAms: () =>
|
|
request<SpoolmanSyncResult>('/spoolman/sync-all', {
|
|
method: 'POST',
|
|
}),
|
|
getSpoolmanSpools: () =>
|
|
request<{ spools: unknown[] }>('/spoolman/spools'),
|
|
/** @deprecated Use getSpoolmanInventoryFilaments() — this endpoint has no SSRF guard */
|
|
getSpoolmanFilaments: () =>
|
|
request<{ filaments: unknown[] }>('/spoolman/filaments'),
|
|
getSpoolmanInventoryFilaments: () =>
|
|
request<SpoolmanFilamentEntry[]>('/spoolman/inventory/filaments'),
|
|
patchSpoolmanFilament: (
|
|
filamentId: number,
|
|
data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean },
|
|
) =>
|
|
request<SpoolmanFilamentEntry>(`/spoolman/inventory/filaments/${filamentId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getUnlinkedSpools: () =>
|
|
request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
|
|
getLinkedSpools: () =>
|
|
request<LinkedSpoolsMap>('/spoolman/spools/linked'),
|
|
linkSpool: (
|
|
spoolId: number,
|
|
context: {
|
|
spoolTag: string;
|
|
printerId: number;
|
|
amsId: number;
|
|
trayId: number;
|
|
}
|
|
) =>
|
|
request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
spool_tag: context.spoolTag,
|
|
printer_id: context.printerId,
|
|
ams_id: context.amsId,
|
|
tray_id: context.trayId,
|
|
}),
|
|
}),
|
|
unlinkSpool: (spoolId: number) =>
|
|
request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/unlink`, {
|
|
method: 'POST',
|
|
}),
|
|
createSpoolmanSpoolFromSlot: (data: { printer_id: number; ams_id: number; tray_id: number }) =>
|
|
request<{ success: boolean; spool_id: number | null }>(`/spoolman/spools/from-slot`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
createSpoolFromSlot: (data: { printer_id: number; ams_id: number; tray_id: number }) =>
|
|
request<InventorySpool>(`/inventory/spools/from-slot`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getSpoolmanSettings: () =>
|
|
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; auto_add_unknown_rfid: string; }>('/settings/spoolman'),
|
|
updateSpoolmanSettings: (data: { spoolman_enabled?: string; spoolman_url?: string; spoolman_sync_mode?: string; spoolman_disable_weight_sync?: string; spoolman_report_partial_usage?: string; auto_add_unknown_rfid?: string; }) =>
|
|
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: string; auto_add_unknown_rfid: string; }>('/settings/spoolman', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
// Inventory
|
|
getSpools: (includeArchived = false) =>
|
|
request<InventorySpool[]>(`/inventory/spools?include_archived=${includeArchived}`),
|
|
getSpool: (id: number) => request<InventorySpool>(`/inventory/spools/${id}`),
|
|
createSpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
|
|
request<InventorySpool>('/inventory/spools', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
bulkCreateSpools: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>, quantity: number) =>
|
|
request<InventorySpool[]>('/inventory/spools/bulk', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool: data, quantity }),
|
|
}),
|
|
// ── CSV import/export (#1576) ────────────────────────────────────────────
|
|
// dry_run=true → preview (no write); omitted → real import. Both share one
|
|
// multipart upload helper; see `uploadSpoolsCsv` below.
|
|
importSpoolsCsvPreview: (file: File): Promise<CsvImportPreview> => uploadSpoolsCsv<CsvImportPreview>(file, true),
|
|
importSpoolsCsv: (file: File): Promise<CsvImportResult> => uploadSpoolsCsv<CsvImportResult>(file, false),
|
|
exportSpoolsCsv: async (): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
const response = await fetch(`${API_BASE}/inventory/spools/export`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const filename = parseContentDispositionFilename(disposition) || 'bambuddy_inventory.csv';
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
updateSpool: (id: number, data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
|
|
request<InventorySpool>(`/inventory/spools/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteSpool: (id: number) =>
|
|
request<{ status: string }>(`/inventory/spools/${id}`, { method: 'DELETE' }),
|
|
archiveSpool: (id: number) =>
|
|
request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
|
|
restoreSpool: (id: number) =>
|
|
request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
|
|
resetSpoolConsumedCounter: (id: number) =>
|
|
request<InventorySpool>(`/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
|
|
bulkResetSpoolConsumedCounter: (spoolIds: number[]) =>
|
|
request<{ reset: number }>(`/inventory/spools/reset-consumed-counter-bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool_ids: spoolIds }),
|
|
}),
|
|
bulkUpdateSpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
|
|
request<{ updated: number; not_found: number[] }>(`/inventory/spools/bulk-update`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, update }),
|
|
}),
|
|
bulkDeleteSpools: (ids: number[]) =>
|
|
request<{ deleted: number; not_found: number[] }>(`/inventory/spools/bulk-delete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
bulkArchiveSpools: (ids: number[]) =>
|
|
request<{ archived: number; already_archived: number[]; not_found: number[] }>(`/inventory/spools/bulk-archive`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
bulkRestoreSpools: (ids: number[]) =>
|
|
request<{ restored: number; already_active: number[]; not_found: number[] }>(`/inventory/spools/bulk-restore`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
getSpoolKProfiles: (spoolId: number) =>
|
|
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
|
|
saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
|
|
request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(profiles),
|
|
}),
|
|
/**
|
|
* What the spool assigned to this slot is configured to use *here* -- its
|
|
* per-printer-model filament preset and the K profile for this slot's hotend.
|
|
* Nulls when the slot holds no known spool.
|
|
*/
|
|
getSlotSpoolDefaults: (printerId: number, amsId: number, trayId: number) =>
|
|
request<SlotSpoolDefaults>(`/printers/${printerId}/slots/${amsId}/${trayId}/spool-defaults`),
|
|
getSpoolFilamentPresets: (spoolId: number) =>
|
|
request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`),
|
|
saveSpoolFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
|
|
request<SpoolFilamentPreset[]>(`/inventory/spools/${spoolId}/filament-presets`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(presets),
|
|
}),
|
|
getAssignments: (printerId?: number) =>
|
|
request<SpoolAssignment[]>(`/inventory/assignments${printerId ? `?printer_id=${printerId}` : ''}`),
|
|
assignSpool: (data: { spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
|
|
request<SpoolAssignment>('/inventory/assignments', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
unassignSpool: (printerId: number, amsId: number, trayId: number) =>
|
|
request<{ status: string }>(`/inventory/assignments/${printerId}/${amsId}/${trayId}`, { method: 'DELETE' }),
|
|
// ── Spool label printing (#809) ──────────────────────────────────────────
|
|
// Both endpoints return application/pdf. Frontend opens the resulting Blob
|
|
// in a new tab so the user can print or save from the browser's PDF viewer.
|
|
printSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise<Blob> => {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
const response = await fetch(`${API_BASE}/inventory/labels`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.blob();
|
|
},
|
|
printSpoolmanSpoolLabels: async (data: PrintSpoolLabelsRequest): Promise<Blob> => {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
|
|
const response = await fetch(`${API_BASE}/spoolman/labels`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.blob();
|
|
},
|
|
getSpoolCatalog: () =>
|
|
request<SpoolCatalogEntry[]>('/inventory/catalog'),
|
|
addCatalogEntry: (data: { name: string; weight: number }) =>
|
|
request<SpoolCatalogEntry>('/inventory/catalog', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateCatalogEntry: (id: number, data: { name: string; weight: number }) =>
|
|
request<SpoolCatalogEntry>(`/inventory/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteCatalogEntry: (id: number) =>
|
|
request<{ status: string }>(`/inventory/catalog/${id}`, { method: 'DELETE' }),
|
|
bulkDeleteCatalogEntries: (ids: number[]) =>
|
|
request<{ deleted: number }>('/inventory/catalog/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
|
|
resetSpoolCatalog: () =>
|
|
request<{ status: string }>('/inventory/catalog/reset', { method: 'POST' }),
|
|
getLocations: () =>
|
|
request<StorageLocation[]>('/inventory/locations'),
|
|
createLocation: (data: { name: string; identifier?: string | null }) =>
|
|
request<StorageLocation>('/inventory/locations', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateLocation: (id: number, data: { name?: string; identifier?: string | null }) =>
|
|
request<StorageLocation>(`/inventory/locations/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
|
|
deleteLocation: (id: number) =>
|
|
request<{ status: string }>(`/inventory/locations/${id}`, { method: 'DELETE' }),
|
|
getColorCatalog: () =>
|
|
request<ColorCatalogEntry[]>('/inventory/colors'),
|
|
/** Flat hex→name map, plus the names collapsing it loses. ``by_material`` is
|
|
* keyed ``"<material>|<hex>"`` and only carries entries that differ from the
|
|
* flat answer (e.g. ``"pla matte|ffffff" -> "Ivory White"`` where the flat
|
|
* map says "Jade White"). #2875. */
|
|
getColorNameMap: () =>
|
|
request<{ colors: Record<string, string>; by_material?: Record<string, string> }>('/inventory/colors/map'),
|
|
addColorEntry: (data: {
|
|
manufacturer: string;
|
|
color_name: string;
|
|
hex_color: string;
|
|
material: string | null;
|
|
extra_colors?: string | null;
|
|
effect_type?: string | null;
|
|
}) =>
|
|
request<ColorCatalogEntry>('/inventory/colors', { method: 'POST', body: JSON.stringify(data) }),
|
|
updateColorEntry: (
|
|
id: number,
|
|
data: {
|
|
manufacturer: string;
|
|
color_name: string;
|
|
hex_color: string;
|
|
material: string | null;
|
|
extra_colors?: string | null;
|
|
effect_type?: string | null;
|
|
},
|
|
) => request<ColorCatalogEntry>(`/inventory/colors/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
|
deleteColorEntry: (id: number) =>
|
|
request<{ status: string }>(`/inventory/colors/${id}`, { method: 'DELETE' }),
|
|
bulkDeleteColorEntries: (ids: number[]) =>
|
|
request<{ deleted: number }>('/inventory/colors/bulk-delete', { method: 'POST', body: JSON.stringify({ ids }) }),
|
|
resetColorCatalog: () =>
|
|
request<{ status: string }>('/inventory/colors/reset', { method: 'POST' }),
|
|
lookupColor: (manufacturer: string, colorName: string, material?: string) =>
|
|
request<ColorLookupResult>(`/inventory/colors/lookup?manufacturer=${encodeURIComponent(manufacturer)}&color_name=${encodeURIComponent(colorName)}${material ? `&material=${encodeURIComponent(material)}` : ''}`),
|
|
searchColors: (manufacturer?: string, material?: string) =>
|
|
request<ColorCatalogEntry[]>(`/inventory/colors/search?${manufacturer ? `manufacturer=${encodeURIComponent(manufacturer)}` : ''}${manufacturer && material ? '&' : ''}${material ? `material=${encodeURIComponent(material)}` : ''}`),
|
|
linkTagToSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string; tag_type?: string; data_origin?: string }) =>
|
|
request<InventorySpool>(`/inventory/spools/${spoolId}/link-tag`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getSpoolUsageHistory: (spoolId: number, limit = 50) =>
|
|
request<SpoolUsageRecord[]>(`/inventory/spools/${spoolId}/usage?limit=${limit}`),
|
|
getAllUsageHistory: (limit = 100, printerId?: number) =>
|
|
request<SpoolUsageRecord[]>(`/inventory/usage?limit=${limit}${printerId ? `&printer_id=${printerId}` : ''}`),
|
|
clearSpoolUsageHistory: (spoolId: number) =>
|
|
request<{ status: string }>(`/inventory/spools/${spoolId}/usage`, { method: 'DELETE' }),
|
|
syncWeightsFromAms: () =>
|
|
request<{ synced: number; skipped: number }>('/inventory/sync-ams-weights', { method: 'POST' }),
|
|
getSkuSettings: () =>
|
|
request<FilamentSkuSettings[]>('/inventory/sku-settings'),
|
|
upsertSkuSettings: (data: Omit<FilamentSkuSettings, 'id'>) =>
|
|
request<FilamentSkuSettings>('/inventory/sku-settings', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
getShoppingList: () =>
|
|
request<ShoppingListItem[]>('/inventory/shopping-list'),
|
|
addToShoppingList: (data: ShoppingListItemCreate) =>
|
|
request<ShoppingListItem>('/inventory/shopping-list', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
removeFromShoppingList: (id: number) =>
|
|
request<{ status: string }>(`/inventory/shopping-list/${id}`, { method: 'DELETE' }),
|
|
clearShoppingList: () =>
|
|
request<{ deleted: number }>('/inventory/shopping-list', { method: 'DELETE' }),
|
|
updateShoppingListStatus: (id: number, status: 'pending' | 'purchased' | 'received') =>
|
|
request<ShoppingListItem>(`/inventory/shopping-list/${id}/status`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ status }),
|
|
}),
|
|
getFilamentPresets: () =>
|
|
request<SlicerSetting[]>('/cloud/filaments'),
|
|
|
|
// Spoolman Inventory proxy (unified UI when Spoolman is enabled)
|
|
getSpoolmanInventorySpools: (includeArchived = false) =>
|
|
request<InventorySpool[]>(`/spoolman/inventory/spools?include_archived=${includeArchived}`),
|
|
getSpoolmanInventorySpool: (id: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}`),
|
|
createSpoolmanInventorySpool: (data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>) =>
|
|
request<InventorySpool>('/spoolman/inventory/spools', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
bulkCreateSpoolmanInventorySpools: (
|
|
data: Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>,
|
|
quantity: number,
|
|
) =>
|
|
request<SpoolmanBulkCreateResult | InventorySpool[]>('/spoolman/inventory/spools/bulk', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool: data, quantity }),
|
|
}),
|
|
updateSpoolmanInventorySpool: (
|
|
id: number,
|
|
data: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>,
|
|
) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteSpoolmanInventorySpool: (id: number) =>
|
|
request<{ status: string }>(`/spoolman/inventory/spools/${id}`, { method: 'DELETE' }),
|
|
archiveSpoolmanInventorySpool: (id: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
|
|
restoreSpoolmanInventorySpool: (id: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
|
|
resetSpoolmanInventorySpoolConsumedCounter: (id: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-consumed-counter`, { method: 'POST' }),
|
|
bulkResetSpoolmanInventorySpoolConsumedCounter: (spoolIds: number[]) =>
|
|
request<{ reset: number }>(`/spoolman/inventory/spools/reset-consumed-counter-bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool_ids: spoolIds }),
|
|
}),
|
|
bulkUpdateSpoolmanInventorySpools: (ids: number[], update: Partial<Omit<InventorySpool, 'id' | 'archived_at' | 'created_at' | 'updated_at' | 'k_profiles'>>) =>
|
|
request<{ updated: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-update`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids, update }),
|
|
}),
|
|
bulkDeleteSpoolmanInventorySpools: (ids: number[]) =>
|
|
request<{ deleted: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-delete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
bulkArchiveSpoolmanInventorySpools: (ids: number[]) =>
|
|
request<{ archived: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-archive`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
bulkRestoreSpoolmanInventorySpools: (ids: number[]) =>
|
|
request<{ restored: number; errors: Array<{ id: number; status: number; detail: string }> }>(`/spoolman/inventory/spools/bulk-restore`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
syncSpoolmanSpoolWeight: (spoolId: number, weightGrams: number) =>
|
|
request<{ status: string; weight_used: number }>(`/spoolman/inventory/spools/${spoolId}/weight`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ weight_grams: weightGrams }),
|
|
}),
|
|
assignSpoolmanSlot: (data: { spoolman_spool_id: number; printer_id: number; ams_id: number; tray_id: number }) =>
|
|
request<InventorySpool>('/spoolman/inventory/slot-assignments', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
unassignSpoolmanSlot: (spoolmanSpoolId: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/slot-assignments/${spoolmanSpoolId}`, { method: 'DELETE' }),
|
|
getSpoolmanSlotAssignment: (printerId: number, amsId: number, trayId: number) =>
|
|
request<InventorySpool | null>(
|
|
`/spoolman/inventory/slot-assignments?printer_id=${printerId}&ams_id=${amsId}&tray_id=${trayId}`,
|
|
),
|
|
getSpoolmanSlotAssignments: (printerId?: number) =>
|
|
request<Array<{
|
|
printer_id: number;
|
|
printer_name: string | null;
|
|
ams_id: number;
|
|
tray_id: number;
|
|
spoolman_spool_id: number;
|
|
ams_label: string | null;
|
|
}>>(
|
|
printerId !== undefined
|
|
? `/spoolman/inventory/slot-assignments/all?printer_id=${printerId}`
|
|
: '/spoolman/inventory/slot-assignments/all',
|
|
),
|
|
syncSpoolmanAmsWeights: () =>
|
|
request<{ synced: number; skipped: number }>('/spoolman/inventory/sync-ams-weights', { method: 'POST' }),
|
|
|
|
getSpoolmanKProfiles: (spoolId: number) =>
|
|
request<SpoolKProfile[]>(`/spoolman/inventory/spools/${spoolId}/k-profiles`),
|
|
|
|
saveSpoolmanKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
|
|
request<SpoolKProfile[]>(`/spoolman/inventory/spools/${spoolId}/k-profiles`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(profiles),
|
|
}),
|
|
|
|
getSpoolmanFilamentPresets: (spoolId: number) =>
|
|
request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`),
|
|
|
|
saveSpoolmanFilamentPresets: (spoolId: number, presets: SpoolFilamentPresetInput[]) =>
|
|
request<SpoolFilamentPreset[]>(`/spoolman/inventory/spools/${spoolId}/filament-presets`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(presets),
|
|
}),
|
|
|
|
// Updates
|
|
getVersion: () => request<VersionInfo>('/updates/version'),
|
|
checkForUpdates: () => request<UpdateCheckResult>('/updates/check'),
|
|
applyUpdate: () =>
|
|
request<{ success: boolean; message: string; status?: UpdateStatus; is_docker?: boolean; is_ha_addon?: boolean; is_windows_installer?: boolean }>('/updates/apply', {
|
|
method: 'POST',
|
|
}),
|
|
getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
|
|
|
|
// Maintenance
|
|
getMaintenanceTypes: () => request<MaintenanceType[]>('/maintenance/types'),
|
|
createMaintenanceType: (data: MaintenanceTypeCreate) =>
|
|
request<MaintenanceType>('/maintenance/types', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateMaintenanceType: (id: number, data: Partial<MaintenanceTypeCreate>) =>
|
|
request<MaintenanceType>(`/maintenance/types/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteMaintenanceType: (id: number) =>
|
|
request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
|
|
restoreDefaultMaintenanceTypes: () =>
|
|
request<{ restored: number }>(`/maintenance/types/restore-defaults`, { method: 'POST' }),
|
|
getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
|
|
getPrinterMaintenance: (printerId: number) =>
|
|
request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
|
|
updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean }) =>
|
|
request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
performMaintenance: (itemId: number, notes?: string) =>
|
|
request<MaintenanceStatus>(`/maintenance/items/${itemId}/perform`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ notes }),
|
|
}),
|
|
getMaintenanceHistory: (itemId: number) =>
|
|
request<MaintenanceHistory[]>(`/maintenance/items/${itemId}/history`),
|
|
getMaintenanceSummary: () => request<MaintenanceSummary>('/maintenance/summary'),
|
|
setPrinterHours: (printerId: number, totalHours: number) =>
|
|
request<{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }>(
|
|
`/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
|
|
{ method: 'PATCH' }
|
|
),
|
|
assignMaintenanceType: (printerId: number, typeId: number) =>
|
|
request<MaintenanceStatus>(`/maintenance/printers/${printerId}/assign/${typeId}`, {
|
|
method: 'POST',
|
|
}),
|
|
removeMaintenanceItem: (itemId: number) =>
|
|
request<{ status: string }>(`/maintenance/items/${itemId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// Camera
|
|
getCameraStreamToken: () =>
|
|
request<{ token: string }>('/printers/camera/stream-token', { method: 'POST' }),
|
|
|
|
// WebSocket auth (GHSA-r2qv follow-up) — mint a short-lived token for
|
|
// the /ws connection. Browsers can't attach Authorization headers to a
|
|
// WebSocket handshake, so the token rides in the ?token= query param.
|
|
getWebSocketToken: () =>
|
|
request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
|
|
|
|
// Long-lived camera tokens (#1108, #2531)
|
|
createLongLivedCameraToken: (payload: {
|
|
name: string;
|
|
expires_in_days: number;
|
|
scope?: LongLivedTokenScope;
|
|
}) =>
|
|
request<LongLivedCameraToken>('/auth/tokens', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ scope: 'camera_stream', ...payload }),
|
|
}),
|
|
listMyLongLivedCameraTokens: () =>
|
|
request<LongLivedCameraToken[]>('/auth/tokens'),
|
|
listAllLongLivedCameraTokens: () =>
|
|
request<LongLivedCameraToken[]>('/auth/tokens/all'),
|
|
listLongLivedCameraTokensForUser: (userId: number) =>
|
|
request<LongLivedCameraToken[]>(`/auth/tokens?user_id=${userId}`),
|
|
revokeLongLivedCameraToken: (tokenId: number) =>
|
|
request<void>(`/auth/tokens/${tokenId}`, { method: 'DELETE' }),
|
|
// Token-authenticated Cam Wall feed (#2531). `token` is omitted only when
|
|
// auth is disabled, where the backend gate is a no-op anyway.
|
|
getCamWallPrinters: (token?: string) =>
|
|
request<CamWallPrinter[]>(
|
|
token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
|
|
),
|
|
// Token-authenticated streaming-overlay feed (#2613). OBS (or any embed with
|
|
// no login session) loads /overlay/{id}?token=... and this backs it. `token`
|
|
// is omitted only when auth is disabled, where the backend gate is a no-op.
|
|
getOverlayStatus: (printerId: number, token?: string) =>
|
|
request<OverlayStatus>(
|
|
token
|
|
? `/printers/${printerId}/overlay-status?token=${encodeURIComponent(token)}`
|
|
: `/printers/${printerId}/overlay-status`,
|
|
),
|
|
getCameraStreamUrl: (printerId: number, fps = 10) =>
|
|
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
|
|
getCameraSnapshotUrl: (printerId: number) =>
|
|
withStreamToken(`${API_BASE}/printers/${printerId}/camera/snapshot`),
|
|
testCameraConnection: (printerId: number) =>
|
|
request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
|
|
getCameraStatus: (printerId: number) =>
|
|
request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
|
|
diagnoseCamera: (printerId: number) =>
|
|
request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
|
|
diagnosePrinter: (printerId: number) =>
|
|
request<PrinterDiagnosticResult>(`/printers/${printerId}/diagnostic`),
|
|
diagnoseConnection: (body: {
|
|
ip_address: string;
|
|
serial_number?: string;
|
|
access_code?: string;
|
|
}) =>
|
|
request<PrinterDiagnosticResult>('/printers/diagnostic', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
|
|
// Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
|
|
checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
|
|
const params = new URLSearchParams();
|
|
// Only forward use_external when the caller explicitly sets it. Omitted →
|
|
// backend derives the default from the printer's external_camera_enabled
|
|
// setting so calibration and runtime checks use the same camera (#1359).
|
|
if (options?.useExternal !== undefined) {
|
|
params.set('use_external', String(options.useExternal));
|
|
}
|
|
params.set('include_debug_image', String(options?.includeDebugImage ?? false));
|
|
return request<PlateDetectionResult>(
|
|
`/printers/${printerId}/camera/check-plate?${params.toString()}`
|
|
);
|
|
},
|
|
getPlateDetectionStatus: (printerId: number) => {
|
|
return request<PlateDetectionStatus & { chamber_light?: boolean }>(
|
|
`/printers/${printerId}/camera/plate-detection/status`
|
|
);
|
|
},
|
|
calibratePlateDetection: (printerId: number, options?: { label?: string; useExternal?: boolean }) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.label) params.set('label', options.label);
|
|
if (options?.useExternal !== undefined) {
|
|
params.set('use_external', String(options.useExternal));
|
|
}
|
|
return request<CalibrationResult & { index: number }>(
|
|
`/printers/${printerId}/camera/plate-detection/calibrate?${params.toString()}`,
|
|
{ method: 'POST' }
|
|
);
|
|
},
|
|
deletePlateCalibration: (printerId: number) => {
|
|
return request<CalibrationResult>(
|
|
`/printers/${printerId}/camera/plate-detection/calibrate`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
},
|
|
getPlateReferences: (printerId: number) => {
|
|
return request<{
|
|
references: PlateReference[];
|
|
max_references: number;
|
|
}>(`/printers/${printerId}/camera/plate-detection/references`);
|
|
},
|
|
getPlateReferenceThumbnailUrl: (printerId: number, index: number) =>
|
|
withStreamToken(`${API_BASE}/printers/${printerId}/camera/plate-detection/references/${index}/thumbnail`),
|
|
updatePlateReferenceLabel: (printerId: number, index: number, label: string) => {
|
|
const params = new URLSearchParams();
|
|
params.set('label', label);
|
|
return request<{ success: boolean; index: number; label: string }>(
|
|
`/printers/${printerId}/camera/plate-detection/references/${index}?${params.toString()}`,
|
|
{ method: 'PUT' }
|
|
);
|
|
},
|
|
deletePlateReference: (printerId: number, index: number) => {
|
|
return request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/camera/plate-detection/references/${index}`,
|
|
{ method: 'DELETE' }
|
|
);
|
|
},
|
|
|
|
// External Links
|
|
getExternalLinks: () => request<ExternalLink[]>('/external-links/'),
|
|
getExternalLink: (id: number) => request<ExternalLink>(`/external-links/${id}`),
|
|
createExternalLink: (data: ExternalLinkCreate) =>
|
|
request<ExternalLink>('/external-links/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateExternalLink: (id: number, data: ExternalLinkUpdate) =>
|
|
request<ExternalLink>(`/external-links/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteExternalLink: (id: number) =>
|
|
request<{ message: string }>(`/external-links/${id}`, { method: 'DELETE' }),
|
|
reorderExternalLinks: (ids: number[]) =>
|
|
request<ExternalLink[]>('/external-links/reorder', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ ids }),
|
|
}),
|
|
uploadExternalLinkIcon: async (id: number, file: File): Promise<ExternalLink> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
deleteExternalLinkIcon: (id: number) =>
|
|
request<ExternalLink>(`/external-links/${id}/icon`, { method: 'DELETE' }),
|
|
getExternalLinkIconUrl: (id: number) => withStreamToken(`${API_BASE}/external-links/${id}/icon`),
|
|
|
|
// Projects
|
|
getProjects: (status?: string) => {
|
|
const params = new URLSearchParams();
|
|
if (status) params.set('status', status);
|
|
return request<ProjectListItem[]>(`/projects/?${params}`);
|
|
},
|
|
getProject: (id: number) => request<Project>(`/projects/${id}`),
|
|
createProject: (data: ProjectCreate) =>
|
|
request<Project>('/projects/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateProject: (id: number, data: ProjectUpdate) =>
|
|
request<Project>(`/projects/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteProject: (id: number) =>
|
|
request<{ message: string }>(`/projects/${id}`, { method: 'DELETE' }),
|
|
getProjectArchives: (id: number, limit = 100, offset = 0) =>
|
|
request<Archive[]>(`/projects/${id}/archives?limit=${limit}&offset=${offset}`),
|
|
// Completed-run counts per library file (#1897); files with 0 runs are omitted
|
|
getProjectFileProgress: (id: number) =>
|
|
request<ProjectFileProgress[]>(`/projects/${id}/file-progress`),
|
|
addArchivesToProject: (projectId: number, archiveIds: number[]) =>
|
|
request<{ message: string }>(`/projects/${projectId}/add-archives`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ archive_ids: archiveIds }),
|
|
}),
|
|
removeArchivesFromProject: (projectId: number, archiveIds: number[]) =>
|
|
request<{ message: string }>(`/projects/${projectId}/remove-archives`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ archive_ids: archiveIds }),
|
|
}),
|
|
addQueueItemsToProject: (projectId: number, queueItemIds: number[]) =>
|
|
request<{ message: string }>(`/projects/${projectId}/add-queue`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ queue_item_ids: queueItemIds }),
|
|
}),
|
|
|
|
// Project Attachments
|
|
uploadProjectAttachment: async (projectId: number, file: File): Promise<{
|
|
status: string;
|
|
filename: string;
|
|
original_name: string;
|
|
attachments: ProjectAttachment[];
|
|
}> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/projects/${projectId}/attachments`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
getProjectAttachmentUrl: (projectId: number, filename: string) =>
|
|
`${API_BASE}/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
|
|
deleteProjectAttachment: (projectId: number, filename: string) =>
|
|
request<{ status: string; message: string; attachments: ProjectAttachment[] | null }>(
|
|
`/projects/${projectId}/attachments/${encodeURIComponent(filename)}`,
|
|
{ method: 'DELETE' }
|
|
),
|
|
|
|
// #1155: Cover image
|
|
// Browsers can't attach `Authorization: Bearer ...` to `<img src>`, so we
|
|
// append the stream-token query string the same way archive thumbnails do.
|
|
getProjectCoverImageUrl: (projectId: number) =>
|
|
withStreamToken(`${API_BASE}/projects/${projectId}/cover-image`),
|
|
uploadProjectCoverImage: async (
|
|
projectId: number,
|
|
file: File
|
|
): Promise<{ status: string; filename: string; size: number }> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/projects/${projectId}/cover-image`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
deleteProjectCoverImage: (projectId: number) =>
|
|
request<{ status: string }>(`/projects/${projectId}/cover-image`, { method: 'DELETE' }),
|
|
|
|
// BOM (Bill of Materials)
|
|
getProjectBOM: (projectId: number) =>
|
|
request<BOMItem[]>(`/projects/${projectId}/bom`),
|
|
createBOMItem: (projectId: number, data: BOMItemCreate) =>
|
|
request<BOMItem>(`/projects/${projectId}/bom`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateBOMItem: (projectId: number, itemId: number, data: BOMItemUpdate) =>
|
|
request<BOMItem>(`/projects/${projectId}/bom/${itemId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteBOMItem: (projectId: number, itemId: number) =>
|
|
request<{ status: string; message: string }>(`/projects/${projectId}/bom/${itemId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// Templates
|
|
getTemplates: () => request<ProjectListItem[]>('/projects/templates/'),
|
|
createTemplateFromProject: (projectId: number) =>
|
|
request<Project>(`/projects/${projectId}/create-template`, { method: 'POST' }),
|
|
createProjectFromTemplate: (templateId: number, name?: string) =>
|
|
request<Project>(`/projects/from-template/${templateId}${name ? `?name=${encodeURIComponent(name)}` : ''}`, {
|
|
method: 'POST',
|
|
}),
|
|
|
|
// Timeline
|
|
getProjectTimeline: (projectId: number, limit = 50) =>
|
|
request<TimelineEvent[]>(`/projects/${projectId}/timeline?limit=${limit}`),
|
|
|
|
// Project Export/Import
|
|
exportProjectJson: (projectId: number) =>
|
|
request<ProjectExport>(`/projects/${projectId}/export?format=json`),
|
|
importProject: (data: ProjectImport) =>
|
|
request<Project>('/projects/import', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
importProjectFile: async (file: File): Promise<Project> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/projects/import/file`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
exportProjectZip: async (projectId: number): Promise<{ blob: Blob; filename: string }> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/projects/${projectId}/export`, {
|
|
headers,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const contentDisposition = response.headers.get('Content-Disposition');
|
|
const filename = parseContentDispositionFilename(contentDisposition) || `project_${projectId}.zip`;
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
},
|
|
|
|
// API Keys
|
|
getAPIKeys: () => request<APIKey[]>('/api-keys/'),
|
|
createAPIKey: (data: APIKeyCreate) =>
|
|
request<APIKeyCreateResponse>('/api-keys/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateAPIKey: (id: number, data: APIKeyUpdate) =>
|
|
request<APIKey>(`/api-keys/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteAPIKey: (id: number) =>
|
|
request<{ message: string }>(`/api-keys/${id}`, { method: 'DELETE' }),
|
|
|
|
// AMS History
|
|
getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
|
|
request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
|
|
|
|
// Printer heater (nozzle / bed / chamber) sensor history
|
|
getPrinterSensorHistory: (printerId: number, hours = 24, kinds?: string[]) => {
|
|
const params = new URLSearchParams({ hours: String(hours) });
|
|
if (kinds && kinds.length > 0) params.set('kinds', kinds.join(','));
|
|
return request<PrinterSensorHistoryResponse>(`/printer-sensor-history/${printerId}?${params.toString()}`);
|
|
},
|
|
|
|
// System Info
|
|
getSystemInfo: () => request<SystemInfo>('/system/info'),
|
|
getSystemHealth: () => request<SystemHealthResult>('/system/health'),
|
|
getStorageUsage: (options?: { refresh?: boolean }) => {
|
|
const params = new URLSearchParams();
|
|
if (options?.refresh) {
|
|
params.set('refresh', 'true');
|
|
}
|
|
const query = params.toString();
|
|
return request<StorageUsageResponse>(`/system/storage-usage${query ? `?${query}` : ''}`);
|
|
},
|
|
|
|
// Library (File Manager)
|
|
getLibraryFolders: () => request<LibraryFolderTree[]>('/library/folders'),
|
|
createLibraryFolder: (data: LibraryFolderCreate) =>
|
|
request<LibraryFolder>('/library/folders', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateLibraryFolder: (id: number, data: LibraryFolderUpdate) =>
|
|
request<LibraryFolder>(`/library/folders/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteLibraryFolder: (id: number) =>
|
|
request<{ status: string; message: string }>(`/library/folders/${id}`, { method: 'DELETE' }),
|
|
createExternalFolder: (data: ExternalFolderCreate) =>
|
|
request<LibraryFolder>('/library/folders/external', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
scanExternalFolder: (folderId: number) =>
|
|
request<{ status: string; added: number; removed: number }>(`/library/folders/${folderId}/scan`, {
|
|
method: 'POST',
|
|
}),
|
|
getLibraryFoldersByProject: (projectId: number) =>
|
|
request<LibraryFolder[]>(`/library/folders/by-project/${projectId}`),
|
|
getLibraryFoldersByArchive: (archiveId: number) =>
|
|
request<LibraryFolder[]>(`/library/folders/by-archive/${archiveId}`),
|
|
|
|
getLibraryFiles: (
|
|
folderId?: number | null,
|
|
includeRoot = true,
|
|
projectId?: number,
|
|
scope?: 'internal' | 'external',
|
|
recursive = false,
|
|
tagIds: number[] = [],
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (folderId !== undefined && folderId !== null) {
|
|
params.set('folder_id', String(folderId));
|
|
}
|
|
if (projectId !== undefined) {
|
|
params.set('project_id', String(projectId));
|
|
}
|
|
params.set('include_root', String(includeRoot));
|
|
if (scope === 'internal') params.set('internal_only', 'true');
|
|
else if (scope === 'external') params.set('external_only', 'true');
|
|
// recursive=true expands the folder_id filter to include every descendant
|
|
// folder (#1268). Only meaningful when folder_id is set; ignored server-side
|
|
// otherwise. Off by default so non-search callers keep folder-scoped behavior.
|
|
if (recursive) params.set('recursive', 'true');
|
|
// Tag filter (#1268). Repeated ?tag_ids=N&tag_ids=M form for AND semantics
|
|
// — backend joins the association table and HAVING COUNT(DISTINCT) matches
|
|
// the array length. Tag filter intentionally bypasses folder scoping
|
|
// server-side (cross-cutting design decision).
|
|
for (const tagId of tagIds) {
|
|
params.append('tag_ids', String(tagId));
|
|
}
|
|
return request<LibraryFileListItem[]>(`/library/files?${params}`);
|
|
},
|
|
getLibraryFolderReadme: (folderId: number) =>
|
|
request<{ filename: string; content: string; truncated: boolean }>(
|
|
`/library/folders/${folderId}/readme`,
|
|
),
|
|
|
|
// ============ Library tag catalog (#1268) ============
|
|
getLibraryTags: () =>
|
|
request<LibraryTag[]>('/library/tags'),
|
|
createLibraryTag: (name: string) =>
|
|
request<LibraryTag>('/library/tags', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
updateLibraryTag: (id: number, name: string) =>
|
|
request<LibraryTag>(`/library/tags/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
deleteLibraryTag: (id: number) =>
|
|
request<void>(`/library/tags/${id}`, { method: 'DELETE' }),
|
|
bulkAssignLibraryTags: (
|
|
fileIds: number[],
|
|
tagIds: number[],
|
|
action: 'add' | 'remove' | 'replace',
|
|
) =>
|
|
request<LibraryTagBulkAssignResult>('/library/tags/bulk-assign', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ file_ids: fileIds, tag_ids: tagIds, action }),
|
|
}),
|
|
// ============ Variant groups (#671 / #2570) ============
|
|
// "These files are the same job sliced for different printers." Consumed from
|
|
// both ends: the queue picks a printer and needs the matching file, the File
|
|
// Manager's print action has the printer and needs the same match.
|
|
createVariantGroup: (
|
|
members: { library_file_id: number; target_model?: string }[],
|
|
name?: string,
|
|
) =>
|
|
request<VariantGroup>('/library/variant-groups', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ members, ...(name ? { name } : {}) }),
|
|
}),
|
|
getVariantGroup: (groupId: number) =>
|
|
request<VariantGroup>(`/library/variant-groups/${groupId}`),
|
|
/** Returns null when the file is not grouped, rather than throwing on the 404. */
|
|
getVariantGroupForFile: async (fileId: number): Promise<VariantGroup | null> => {
|
|
try {
|
|
return await request<VariantGroup>(`/library/variant-groups/by-file/${fileId}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
updateVariantGroup: (groupId: number, body: { name?: string; member_file_ids?: number[] }) =>
|
|
request<VariantGroup>(`/library/variant-groups/${groupId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
addVariantGroupMember: (
|
|
groupId: number,
|
|
libraryFileId: number,
|
|
targetModel?: string,
|
|
) =>
|
|
request<VariantGroup>(`/library/variant-groups/${groupId}/members`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
library_file_id: libraryFileId,
|
|
...(targetModel ? { target_model: targetModel } : {}),
|
|
}),
|
|
}),
|
|
removeVariantGroupMember: (groupId: number, fileId: number) =>
|
|
request<void>(`/library/variant-groups/${groupId}/members/${fileId}`, { method: 'DELETE' }),
|
|
deleteVariantGroup: (groupId: number) =>
|
|
request<void>(`/library/variant-groups/${groupId}`, { method: 'DELETE' }),
|
|
|
|
getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
|
|
uploadLibraryFile: async (
|
|
file: File,
|
|
folderId?: number | null,
|
|
generateStlThumbnails: boolean = true
|
|
): Promise<LibraryFileUploadResponse> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const params = new URLSearchParams();
|
|
if (folderId) params.set('folder_id', String(folderId));
|
|
params.set('generate_stl_thumbnails', String(generateStlThumbnails));
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/library/files?${params}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
extractZipFile: async (
|
|
file: File,
|
|
folderId?: number | null,
|
|
preserveStructure: boolean = true,
|
|
createFolderFromZip: boolean = false,
|
|
generateStlThumbnails: boolean = true
|
|
): Promise<ZipExtractResponse> => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const params = new URLSearchParams();
|
|
if (folderId) params.set('folder_id', String(folderId));
|
|
params.set('preserve_structure', String(preserveStructure));
|
|
params.set('create_folder_from_zip', String(createFolderFromZip));
|
|
params.set('generate_stl_thumbnails', String(generateStlThumbnails));
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/library/files/extract-zip?${params}`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
},
|
|
updateLibraryFile: (id: number, data: LibraryFileUpdate) =>
|
|
request<LibraryFile>(`/library/files/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteLibraryFile: (id: number) =>
|
|
request<{ status: string; message: string; trashed: boolean }>(`/library/files/${id}`, { method: 'DELETE' }),
|
|
|
|
// ========== Library Trash (#1008) ==========
|
|
previewLibraryPurge: (olderThanDays: number, includeNeverPrinted: boolean = true) =>
|
|
request<LibraryPurgePreview>(
|
|
`/library/purge/preview?older_than_days=${olderThanDays}&include_never_printed=${includeNeverPrinted}`,
|
|
),
|
|
executeLibraryPurge: (olderThanDays: number, includeNeverPrinted: boolean = true) =>
|
|
request<{ moved_to_trash: number }>('/library/purge', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ older_than_days: olderThanDays, include_never_printed: includeNeverPrinted }),
|
|
}),
|
|
listLibraryTrash: (limit: number = 100, offset: number = 0) =>
|
|
request<LibraryTrashListResponse>(`/library/trash?limit=${limit}&offset=${offset}`),
|
|
restoreLibraryTrash: (fileId: number) =>
|
|
request<{ status: string; id: number }>(`/library/trash/${fileId}/restore`, { method: 'POST' }),
|
|
hardDeleteLibraryTrash: (fileId: number) =>
|
|
request<{ status: string }>(`/library/trash/${fileId}`, { method: 'DELETE' }),
|
|
emptyLibraryTrash: () => request<{ deleted: number }>('/library/trash', { method: 'DELETE' }),
|
|
getLibraryTrashSettings: () =>
|
|
request<LibraryTrashSettings>('/library/trash/settings'),
|
|
updateLibraryTrashSettings: (body: LibraryTrashSettings) =>
|
|
request<LibraryTrashSettings>('/library/trash/settings', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
|
|
createLibrarySlicerToken: (fileId: number) =>
|
|
request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
|
|
getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
|
|
`${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(buildSlicerUrlFilename(filename))}`,
|
|
downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/library/files/${id}/download`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const downloadFilename = parseContentDispositionFilename(disposition) || filename || `file_${id}`;
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = downloadFilename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
getLibraryFileThumbnailUrl: (id: number) => withStreamToken(`${API_BASE}/library/files/${id}/thumbnail`),
|
|
getLibraryFilePlateThumbnail: (id: number, plateIndex: number) =>
|
|
withStreamToken(`${API_BASE}/library/files/${id}/plate-thumbnail/${plateIndex}`),
|
|
getLibraryFileGcodeUrl: (id: number) => `${API_BASE}/library/files/${id}/gcode`,
|
|
moveLibraryFiles: (fileIds: number[], folderId: number | null) =>
|
|
request<{ status: string; moved: number }>('/library/files/move', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ file_ids: fileIds, folder_id: folderId }),
|
|
}),
|
|
bulkDeleteLibrary: (fileIds: number[], folderIds: number[]) =>
|
|
request<{ deleted_files: number; deleted_folders: number }>('/library/bulk-delete', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds }),
|
|
}),
|
|
getLibraryStats: () => request<LibraryStats>('/library/stats'),
|
|
batchGenerateStlThumbnails: (options: {
|
|
file_ids?: number[];
|
|
folder_id?: number;
|
|
all_missing?: boolean;
|
|
}) =>
|
|
request<BatchThumbnailResponse>('/library/generate-stl-thumbnails', {
|
|
method: 'POST',
|
|
body: JSON.stringify(options),
|
|
}),
|
|
addLibraryFilesToQueue: (fileIds: number[]) =>
|
|
request<AddToQueueResponse>('/library/files/add-to-queue', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ file_ids: fileIds }),
|
|
}),
|
|
getLibraryFilePlates: (fileId: number) =>
|
|
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
|
|
getLibraryFileFilamentRequirements: (
|
|
fileId: number,
|
|
plateId?: number,
|
|
requestId?: string,
|
|
/** Ask for one entry per project slot instead of only the slots this
|
|
* plate consumes. The slice modal needs it: its filament list is
|
|
* positional, so a source whose only used slot is 4 must still present
|
|
* four rows or the user's pick is bound to slot 1 (#2712). Print-time
|
|
* AMS matching must NOT set this — it wants the used-only list. */
|
|
fullSlots?: boolean,
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (plateId !== undefined) qs.set('plate_id', String(plateId));
|
|
if (requestId) qs.set('request_id', requestId);
|
|
if (fullSlots) qs.set('full_slots', 'true');
|
|
return request<{
|
|
file_id: number;
|
|
filename: string;
|
|
filaments: Array<{
|
|
slot_id: number;
|
|
type: string;
|
|
color: string;
|
|
used_grams: number;
|
|
used_meters: number;
|
|
used_in_plate?: boolean;
|
|
}>;
|
|
}>(`/library/files/${fileId}/filament-requirements${qs.toString() ? `?${qs}` : ''}`);
|
|
},
|
|
|
|
/** Poll the sidecar's per-request progress snapshot via the Bambuddy
|
|
* proxy. Used by the SliceModal's filament-discovery path so the inline
|
|
* spinner + persistent toast can show "Generating G-code (45%)" while
|
|
* the preview slice runs. Returns null on 404 (sidecar doesn't yet
|
|
* have an entry — early race window — or it expired) so the poller
|
|
* can keep trying. */
|
|
getPreviewSliceProgress: async (requestId: string): Promise<SliceJobProgress | null> => {
|
|
try {
|
|
return await request<SliceJobProgress>(`/slicer/preview-progress/${encodeURIComponent(requestId)}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// GitHub Backup
|
|
getGitHubBackupConfig: () =>
|
|
request<GitHubBackupConfig | null>('/github-backup/config'),
|
|
|
|
getGitHubBackupCloudAccounts: () =>
|
|
request<CloudAccountCounts>('/github-backup/cloud-accounts'),
|
|
|
|
saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
|
|
request<GitHubBackupConfig>('/github-backup/config', {
|
|
method: 'POST',
|
|
body: JSON.stringify(config),
|
|
}),
|
|
|
|
updateGitHubBackupConfig: (config: Partial<GitHubBackupConfigCreate>) =>
|
|
request<GitHubBackupConfig>('/github-backup/config', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(config),
|
|
}),
|
|
|
|
deleteGitHubBackupConfig: () =>
|
|
request<{ message: string }>('/github-backup/config', { method: 'DELETE' }),
|
|
|
|
testGitHubConnection: (repoUrl: string, token: string, provider: GitProviderType = 'github') =>
|
|
request<GitHubTestConnectionResponse>(
|
|
`/github-backup/test?repo_url=${encodeURIComponent(repoUrl)}&token=${encodeURIComponent(token)}&provider=${encodeURIComponent(provider)}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
testGitHubStoredConnection: () =>
|
|
request<GitHubTestConnectionResponse>('/github-backup/test-stored', { method: 'POST' }),
|
|
|
|
triggerGitHubBackup: () =>
|
|
request<GitHubBackupTriggerResponse>('/github-backup/run', { method: 'POST' }),
|
|
|
|
getGitHubBackupStatus: () =>
|
|
request<GitHubBackupStatus>('/github-backup/status'),
|
|
|
|
getGitHubBackupLogs: (limit: number = 50) =>
|
|
request<GitHubBackupLog[]>(`/github-backup/logs?limit=${limit}`),
|
|
|
|
clearGitHubBackupLogs: (keepLast: number = 10) =>
|
|
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
|
|
|
|
// Restore from a Git backup (#2656)
|
|
getGitHubBackupCommits: (limit: number = 20) =>
|
|
request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
|
|
|
|
getGitHubRestorePreview: (ref: string = 'HEAD') =>
|
|
request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
|
|
|
|
restoreFromGitHub: (payload: GitHubRestoreRequest) =>
|
|
request<GitHubRestoreResponse>('/github-backup/restore', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
}),
|
|
|
|
// Scheduled local backups
|
|
getLocalBackupStatus: () =>
|
|
request<LocalBackupStatus>('/local-backup/status'),
|
|
|
|
triggerLocalBackup: () =>
|
|
request<{ success: boolean; message: string; filename?: string }>('/local-backup/run', { method: 'POST' }),
|
|
|
|
checkLocalBackupPath: () =>
|
|
request<LocalBackupPathCheck>('/local-backup/path-check'),
|
|
|
|
getLocalBackups: () =>
|
|
request<LocalBackupFile[]>('/local-backup/backups'),
|
|
|
|
downloadLocalBackup: async (filename: string): Promise<{ blob: Blob; filename: string }> => {
|
|
const response = await fetch(`${API_BASE}/local-backup/backups/${encodeURIComponent(filename)}/download`, {
|
|
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
|
});
|
|
if (!response.ok) throw new Error('Download failed');
|
|
const blob = await response.blob();
|
|
return { blob, filename };
|
|
},
|
|
|
|
restoreLocalBackup: (filename: string) =>
|
|
request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }),
|
|
|
|
deleteLocalBackup: (filename: string) =>
|
|
request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
|
|
|
|
// Obico AI failure detection
|
|
getObicoStatus: () =>
|
|
request<ObicoStatus>('/obico/status'),
|
|
|
|
getObicoPrinterStatus: () =>
|
|
request<ObicoPrinterStatus>('/obico/printer-status'),
|
|
|
|
// `token` is sent as-is, so an empty string tests with no token at all.
|
|
// Omitting the argument makes the backend fall back to the saved token.
|
|
testObicoConnection: (url: string, token?: string) =>
|
|
request<ObicoTestConnection>('/obico/test-connection', {
|
|
method: 'POST',
|
|
body: JSON.stringify(token === undefined ? { url } : { url, token }),
|
|
}),
|
|
|
|
// Slicer API — slice in the background. Both endpoints return 202 + a
|
|
// job_id; poll /slice-jobs/{id} until status is `completed` or `failed`.
|
|
sliceLibraryFile: (fileId: number, body: SliceRequest) =>
|
|
request<SliceJobEnqueueResponse>(`/library/files/${fileId}/slice`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
sliceArchive: (archiveId: number, body: SliceRequest) =>
|
|
request<SliceJobEnqueueResponse>(`/archives/${archiveId}/slice`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
getSliceJob: (jobId: number) =>
|
|
request<SliceJobState>(`/slice-jobs/${jobId}`),
|
|
|
|
// Unified slicer-preset listing — cloud + local + standard, deduped by name.
|
|
// Used by the SliceModal; see UnifiedPresetsResponse for the shape and
|
|
// backend/app/api/routes/slicer_presets.py for the priority rules.
|
|
// `refresh` bypasses the in-process cloud and bundled-preset caches on the
|
|
// backend; the SliceModal's Refresh button passes true so a preset deleted
|
|
// in Bambu Studio or Bambu Handy shows up without the 5-min TTL wait.
|
|
getSlicerPresets: (options?: { refresh?: boolean }) =>
|
|
request<UnifiedPresetsResponse>(
|
|
options?.refresh ? '/slicer/presets?refresh=true' : '/slicer/presets',
|
|
),
|
|
|
|
// Slicer Pipelines (#1425) — preset bundles the SliceModal can apply in
|
|
// one click. CRUD is gated on PIPELINES_READ / PIPELINES_WRITE.
|
|
listSlicerPipelines: () =>
|
|
request<SlicerPipelinesListResponse>('/slicer-pipelines/'),
|
|
getSlicerPipeline: (id: number) =>
|
|
request<SlicerPipeline>(`/slicer-pipelines/${id}`),
|
|
createSlicerPipeline: (data: SlicerPipelineCreateRequest) =>
|
|
request<SlicerPipeline>('/slicer-pipelines/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateSlicerPipeline: (id: number, data: SlicerPipelineUpdateRequest) =>
|
|
request<SlicerPipeline>(`/slicer-pipelines/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteSlicerPipeline: (id: number) =>
|
|
request<void>(`/slicer-pipelines/${id}`, { method: 'DELETE' }),
|
|
checkPipelineEligibility: (
|
|
pipelineId: number,
|
|
source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
|
|
) =>
|
|
request<PipelineEligibilityReport>(`/slicer-pipelines/${pipelineId}/check-eligibility`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(
|
|
source.kind === 'libraryFile'
|
|
? { source_library_file_id: source.id }
|
|
: { source_archive_id: source.id },
|
|
),
|
|
}),
|
|
runPipeline: (
|
|
pipelineId: number,
|
|
source: { kind: 'libraryFile'; id: number } | { kind: 'archive'; id: number },
|
|
force = false,
|
|
copies = 1,
|
|
) =>
|
|
request<PipelineRun>(`/slicer-pipelines/${pipelineId}/run`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
...(source.kind === 'libraryFile'
|
|
? { source_library_file_id: source.id }
|
|
: { source_archive_id: source.id }),
|
|
force,
|
|
copies,
|
|
}),
|
|
}),
|
|
listPipelineRuns: (pipelineId: number, limit = 5) =>
|
|
request<PipelineRunListResponse>(
|
|
`/slicer-pipelines/${pipelineId}/runs?limit=${limit}`,
|
|
),
|
|
// Dashboard list across all pipelines (#1425 PR C).
|
|
listAllPipelineRuns: (params: {
|
|
limit?: number;
|
|
offset?: number;
|
|
pipelineId?: number;
|
|
status?: string;
|
|
targetPrinterId?: number;
|
|
targetModelClass?: string;
|
|
} = {}) => {
|
|
const search = new URLSearchParams();
|
|
if (params.limit) search.set('limit', String(params.limit));
|
|
if (params.offset) search.set('offset', String(params.offset));
|
|
if (params.pipelineId) search.set('pipeline_id', String(params.pipelineId));
|
|
if (params.status) search.set('status', params.status);
|
|
if (params.targetPrinterId) search.set('target_printer_id', String(params.targetPrinterId));
|
|
if (params.targetModelClass) search.set('target_model_class', params.targetModelClass);
|
|
const q = search.toString();
|
|
return request<PipelineRunListResponse>(
|
|
`/pipeline-runs${q ? '?' + q : ''}`,
|
|
);
|
|
},
|
|
// Clear terminal pipeline runs (#1425 PR C polish). Deletes all runs in
|
|
// a terminal state (completed/failed/cancelled/partial_failure); in-flight
|
|
// runs are preserved.
|
|
clearTerminalPipelineRuns: () =>
|
|
request<{ deleted: number }>('/pipeline-runs/clear', { method: 'POST' }),
|
|
getPipelineRun: (runId: number) =>
|
|
request<PipelineRun>(`/pipeline-runs/${runId}`),
|
|
cancelPipelineRun: (runId: number) =>
|
|
request<PipelineRun>(`/pipeline-runs/${runId}/cancel`, { method: 'POST' }),
|
|
retryFailedPipelineRun: (runId: number) =>
|
|
request<PipelineRun>(`/pipeline-runs/${runId}/retry-failed`, { method: 'POST' }),
|
|
|
|
// Canonical Bambu printer-model registry — "Bambu Lab <model>" → short code.
|
|
// Single source of truth shared with backend (PRINTER_MODEL_MAP); the
|
|
// SliceModal uses this to classify cloud / standard presets by their
|
|
// `@BBL <code>` suffix against the selected printer-preset name (#1325).
|
|
getSlicerPrinterModels: () =>
|
|
request<Record<string, string>>('/slicer/printer-models'),
|
|
|
|
/**
|
|
* Effective values of a process preset, with its `inherits:` chain flattened
|
|
* by the slicer sidecar. Powers the slice modal's settings panel, which would
|
|
* otherwise show the option schema's compiled-in defaults (a preset setting a
|
|
* 0.42mm line width appears as the C++ default of 0).
|
|
*
|
|
* `resolved: false` means the values could not be obtained -- sidecar offline,
|
|
* too old for the endpoint, or slicing not configured -- and the caller should
|
|
* fall back to schema defaults rather than treat it as a failure.
|
|
*/
|
|
getSlicerPresetValues: (ref: PresetRef) =>
|
|
request<SlicerPresetValues>(
|
|
`/slicer/preset-values?source=${encodeURIComponent(ref.source)}&id=${encodeURIComponent(ref.id)}`,
|
|
),
|
|
|
|
// Local Presets (OrcaSlicer imports)
|
|
getLocalPresets: () =>
|
|
request<LocalPresetsResponse>('/local-presets/'),
|
|
getLocalPresetDetail: (id: number) =>
|
|
request<LocalPresetDetail>(`/local-presets/${id}`),
|
|
importLocalPresets: (formData: FormData) =>
|
|
fetch(`${API_BASE}/local-presets/import`, {
|
|
method: 'POST',
|
|
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
|
body: formData,
|
|
}).then(async (res) => {
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
return res.json() as Promise<ImportResponse>;
|
|
}),
|
|
createLocalPreset: (data: { name: string; preset_type: string; setting: Record<string, unknown> }) =>
|
|
request<LocalPreset>('/local-presets/', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
updateLocalPreset: (id: number, data: { name?: string; setting?: Record<string, unknown> }) =>
|
|
request<LocalPreset>(`/local-presets/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
deleteLocalPreset: (id: number) =>
|
|
request<{ success: boolean }>(`/local-presets/${id}`, { method: 'DELETE' }),
|
|
refreshBaseProfileCache: () =>
|
|
request<{ refreshed: number; failed: number; total: number }>('/local-presets/base-cache/refresh', { method: 'POST' }),
|
|
};
|
|
|
|
// AMS History types
|
|
export interface AMSHistoryPoint {
|
|
recorded_at: string;
|
|
humidity: number | null;
|
|
humidity_raw: number | null;
|
|
temperature: number | null;
|
|
}
|
|
|
|
export interface AMSHistoryResponse {
|
|
printer_id: number;
|
|
ams_id: number;
|
|
data: AMSHistoryPoint[];
|
|
min_humidity: number | null;
|
|
max_humidity: number | null;
|
|
avg_humidity: number | null;
|
|
min_temperature: number | null;
|
|
max_temperature: number | null;
|
|
avg_temperature: number | null;
|
|
}
|
|
|
|
export type HeaterSensorKind = 'nozzle' | 'nozzle_2' | 'bed' | 'chamber';
|
|
|
|
export interface HeaterHistoryPoint {
|
|
recorded_at: string;
|
|
value: number | null;
|
|
target: number | null;
|
|
}
|
|
|
|
export interface HeaterSeries {
|
|
sensor_kind: HeaterSensorKind;
|
|
data: HeaterHistoryPoint[];
|
|
min_value: number | null;
|
|
max_value: number | null;
|
|
avg_value: number | null;
|
|
}
|
|
|
|
export interface PrinterSensorHistoryResponse {
|
|
printer_id: number;
|
|
series: HeaterSeries[];
|
|
}
|
|
|
|
// System Info types
|
|
export interface SystemInfo {
|
|
app: {
|
|
version: string;
|
|
base_dir: string;
|
|
archive_dir: string;
|
|
};
|
|
database: {
|
|
engine: string;
|
|
version: string;
|
|
archives: number;
|
|
archives_completed: number;
|
|
archives_failed: number;
|
|
archives_printing: number;
|
|
printers: number;
|
|
filaments: number;
|
|
projects: number;
|
|
smart_plugs: number;
|
|
total_print_time_seconds: number;
|
|
total_print_time_formatted: string;
|
|
total_filament_grams: number;
|
|
total_filament_kg: number;
|
|
};
|
|
printers: {
|
|
total: number;
|
|
connected: number;
|
|
connected_list: Array<{
|
|
id: number;
|
|
name: string;
|
|
state: string;
|
|
model: string;
|
|
}>;
|
|
};
|
|
storage: {
|
|
archive_size_bytes: number;
|
|
archive_size_formatted: string;
|
|
database_size_bytes: number;
|
|
database_size_formatted: string;
|
|
disk_total_bytes: number;
|
|
disk_total_formatted: string;
|
|
disk_used_bytes: number;
|
|
disk_used_formatted: string;
|
|
disk_free_bytes: number;
|
|
disk_free_formatted: string;
|
|
disk_percent_used: number;
|
|
};
|
|
system: {
|
|
platform: string;
|
|
platform_release: string;
|
|
platform_version: string;
|
|
architecture: string;
|
|
hostname: string;
|
|
python_version: string;
|
|
uptime_seconds: number;
|
|
uptime_formatted: string;
|
|
boot_time: string;
|
|
};
|
|
memory: {
|
|
total_bytes: number;
|
|
total_formatted: string;
|
|
available_bytes: number;
|
|
available_formatted: string;
|
|
used_bytes: number;
|
|
used_formatted: string;
|
|
percent_used: number;
|
|
};
|
|
cpu: {
|
|
count: number;
|
|
count_logical: number;
|
|
percent: number;
|
|
};
|
|
}
|
|
|
|
export interface StorageUsageCategory {
|
|
key: string;
|
|
label: string;
|
|
bytes: number;
|
|
formatted: string;
|
|
percent_of_total: number;
|
|
}
|
|
|
|
export interface StorageUsageOtherItem {
|
|
bucket: string;
|
|
label: string;
|
|
kind: 'system' | 'data';
|
|
deletable: boolean;
|
|
bytes: number;
|
|
formatted: string;
|
|
percent_of_total: number;
|
|
}
|
|
|
|
export interface StorageUsageResponse {
|
|
roots: string[];
|
|
total_bytes: number;
|
|
total_formatted: string;
|
|
categories: StorageUsageCategory[];
|
|
other_breakdown: StorageUsageOtherItem[];
|
|
scan_errors: number;
|
|
generated_at: string;
|
|
cache: {
|
|
hit: boolean;
|
|
age_seconds: number;
|
|
max_age_seconds: number;
|
|
};
|
|
}
|
|
|
|
// Library (File Manager) types
|
|
export interface LibraryFolderTree {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
project_id: number | null;
|
|
archive_id: number | null;
|
|
project_name: string | null;
|
|
archive_name: string | null;
|
|
is_external: boolean;
|
|
external_path: string | null;
|
|
external_readonly: boolean;
|
|
file_count: number;
|
|
// max(folder.updated_at, max(immediate-child file.updated_at)). Used by
|
|
// the File Manager folder tree's "sort by recent activity" mode (#1770).
|
|
latest_activity_at: string | null;
|
|
children: LibraryFolderTree[];
|
|
}
|
|
|
|
export interface LibraryFolder {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
project_id: number | null;
|
|
archive_id: number | null;
|
|
project_name: string | null;
|
|
archive_name: string | null;
|
|
is_external: boolean;
|
|
external_path: string | null;
|
|
external_readonly: boolean;
|
|
external_show_hidden: boolean;
|
|
file_count: number;
|
|
latest_activity_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface LibraryFolderCreate {
|
|
name: string;
|
|
parent_id?: number | null;
|
|
project_id?: number | null;
|
|
archive_id?: number | null;
|
|
}
|
|
|
|
export interface ExternalFolderCreate {
|
|
name: string;
|
|
external_path: string;
|
|
readonly?: boolean;
|
|
show_hidden?: boolean;
|
|
parent_id?: number | null;
|
|
}
|
|
|
|
export interface LibraryFolderUpdate {
|
|
name?: string;
|
|
parent_id?: number | null;
|
|
project_id?: number | null; // 0 to unlink
|
|
archive_id?: number | null; // 0 to unlink
|
|
}
|
|
|
|
export interface LibraryFileDuplicate {
|
|
id: number;
|
|
filename: string;
|
|
folder_id: number | null;
|
|
folder_name: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface LibraryFile {
|
|
id: number;
|
|
folder_id: number | null;
|
|
folder_name: string | null;
|
|
project_id: number | null;
|
|
project_name: string | null;
|
|
is_external: boolean;
|
|
filename: string;
|
|
file_path: string;
|
|
file_type: string;
|
|
file_size: number;
|
|
file_hash: string | null;
|
|
thumbnail_path: string | null;
|
|
metadata: Record<string, unknown> | null;
|
|
print_count: number;
|
|
last_printed_at: string | null;
|
|
notes: string | null;
|
|
duplicates: LibraryFileDuplicate[] | null;
|
|
duplicate_count: number;
|
|
// User tracking (Issue #206)
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
// Metadata fields
|
|
print_name: string | null;
|
|
print_time_seconds: number | null;
|
|
filament_used_grams: number | null;
|
|
sliced_for_model: string | null;
|
|
}
|
|
|
|
export interface LibraryTagSummary {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
export interface LibraryFileListItem {
|
|
id: number;
|
|
folder_id: number | null;
|
|
is_external: boolean;
|
|
filename: string;
|
|
file_type: string;
|
|
file_size: number;
|
|
thumbnail_path: string | null;
|
|
print_count: number;
|
|
duplicate_count: number;
|
|
// User tracking (Issue #206)
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
created_at: string;
|
|
// Real on-disk modification time (#2680). Null for managed uploads; the date
|
|
// sort and "Modified" column use `fs_modified_at ?? created_at`.
|
|
fs_modified_at: string | null;
|
|
print_name: string | null;
|
|
print_time_seconds: number | null;
|
|
filament_used_grams: number | null;
|
|
sliced_for_model: string | null;
|
|
// Tags assigned to this file (#1268). The backend always emits an empty
|
|
// array when a file has no tags, but the field is typed optional so any
|
|
// legacy code path (or mock) that constructs a LibraryFileListItem without
|
|
// it doesn't crash the renderer. Read sites use `file.tags ?? []`.
|
|
tags?: LibraryTagSummary[];
|
|
// Variant grouping (#671 / #2570). `variant_count` is the size of the whole
|
|
// group, which may include files in other folders — never the number of
|
|
// matching rows on screen. 0 when the file is not grouped.
|
|
variant_group_id?: number | null;
|
|
variant_count?: number;
|
|
}
|
|
|
|
// Variant groups (#671 / #2570): the same job sliced for different printers.
|
|
export interface VariantGroupMember {
|
|
library_file_id: number;
|
|
filename: string;
|
|
target_model: string;
|
|
position: number;
|
|
}
|
|
|
|
export interface VariantGroup {
|
|
id: number;
|
|
name: string;
|
|
/** In priority order — index 0 wins when several printers are free at once. */
|
|
members: VariantGroupMember[];
|
|
}
|
|
|
|
// Library tag catalog (#1268)
|
|
export interface LibraryTag {
|
|
id: number;
|
|
name: string;
|
|
file_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface LibraryTagBulkAssignResult {
|
|
files_updated: number;
|
|
associations_added: number;
|
|
associations_removed: number;
|
|
}
|
|
|
|
export interface LibraryFileUpdate {
|
|
filename?: string;
|
|
folder_id?: number | null;
|
|
project_id?: number | null;
|
|
notes?: string | null;
|
|
}
|
|
|
|
// Library trash (#1008)
|
|
export interface LibraryTrashItem {
|
|
id: number;
|
|
filename: string;
|
|
file_size: number;
|
|
thumbnail_path: string | null;
|
|
folder_id: number | null;
|
|
folder_name: string | null;
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
deleted_at: string;
|
|
auto_purge_at: string;
|
|
}
|
|
|
|
export interface LibraryTrashListResponse {
|
|
items: LibraryTrashItem[];
|
|
total: number;
|
|
retention_days: number;
|
|
}
|
|
|
|
export interface LibraryPurgePreview {
|
|
count: number;
|
|
total_bytes: number;
|
|
sample_filenames: string[];
|
|
older_than_days: number;
|
|
include_never_printed: boolean;
|
|
}
|
|
|
|
export interface LibraryTrashSettings {
|
|
retention_days: number;
|
|
auto_purge_enabled: boolean;
|
|
auto_purge_days: number;
|
|
auto_purge_include_never_printed: boolean;
|
|
}
|
|
|
|
export interface ArchivePurgePreview {
|
|
count: number;
|
|
total_bytes: number;
|
|
sample_filenames: string[];
|
|
older_than_days: number;
|
|
}
|
|
|
|
export interface ArchivePurgeSettings {
|
|
enabled: boolean;
|
|
days: number;
|
|
// #1390: when true, bulk-deletes the linked PrintLogEntry rows so the
|
|
// contribution drops from Quick Stats too. Default false — soft-delete,
|
|
// Quick Stats preserved.
|
|
purge_stats: boolean;
|
|
}
|
|
|
|
export interface LibraryFileUploadResponse {
|
|
id: number;
|
|
filename: string;
|
|
file_type: string;
|
|
file_size: number;
|
|
thumbnail_path: string | null;
|
|
duplicate_of: number | null;
|
|
metadata: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface LibraryStats {
|
|
total_files: number;
|
|
total_folders: number;
|
|
total_size_bytes: number;
|
|
files_by_type: Record<string, number>;
|
|
total_prints: number;
|
|
disk_free_bytes: number;
|
|
disk_total_bytes: number;
|
|
disk_used_bytes: number;
|
|
}
|
|
|
|
export interface ZipExtractResult {
|
|
filename: string;
|
|
file_id: number;
|
|
folder_id: number | null;
|
|
}
|
|
|
|
export interface ZipExtractError {
|
|
filename: string;
|
|
error: string;
|
|
}
|
|
|
|
export interface ZipExtractResponse {
|
|
extracted: number;
|
|
folders_created: number;
|
|
files: ZipExtractResult[];
|
|
errors: ZipExtractError[];
|
|
}
|
|
|
|
// STL Thumbnail Generation types
|
|
export interface BatchThumbnailResult {
|
|
file_id: number;
|
|
filename: string;
|
|
success: boolean;
|
|
error?: string | null;
|
|
}
|
|
|
|
export interface BatchThumbnailResponse {
|
|
processed: number;
|
|
succeeded: number;
|
|
failed: number;
|
|
results: BatchThumbnailResult[];
|
|
}
|
|
|
|
// Library Queue types
|
|
export interface AddToQueueResult {
|
|
file_id: number;
|
|
filename: string;
|
|
queue_item_id: number;
|
|
archive_id: number;
|
|
}
|
|
|
|
export interface AddToQueueError {
|
|
file_id: number;
|
|
filename: string;
|
|
error: string;
|
|
}
|
|
|
|
export interface AddToQueueResponse {
|
|
added: AddToQueueResult[];
|
|
errors: AddToQueueError[];
|
|
}
|
|
|
|
// Discovery types
|
|
export interface DiscoveredPrinter {
|
|
serial: string;
|
|
name: string;
|
|
ip_address: string;
|
|
model: string | null;
|
|
discovered_at: string | null;
|
|
}
|
|
|
|
export interface DiscoveryStatus {
|
|
running: boolean;
|
|
}
|
|
|
|
export interface DiscoveryInfo {
|
|
is_docker: boolean;
|
|
ssdp_running: boolean;
|
|
scan_running: boolean;
|
|
subnets: string[];
|
|
}
|
|
|
|
export interface SubnetScanStatus {
|
|
running: boolean;
|
|
scanned: number;
|
|
total: number;
|
|
}
|
|
|
|
// Discovery API
|
|
export const discoveryApi = {
|
|
getInfo: () => request<DiscoveryInfo>('/discovery/info'),
|
|
|
|
getStatus: () => request<DiscoveryStatus>('/discovery/status'),
|
|
|
|
startDiscovery: (duration: number = 10) =>
|
|
request<DiscoveryStatus>(`/discovery/start?duration=${duration}`, { method: 'POST' }),
|
|
|
|
stopDiscovery: () =>
|
|
request<DiscoveryStatus>('/discovery/stop', { method: 'POST' }),
|
|
|
|
getDiscoveredPrinters: () =>
|
|
request<DiscoveredPrinter[]>('/discovery/printers'),
|
|
|
|
// Subnet scanning (for Docker environments)
|
|
startSubnetScan: (subnet: string, timeout: number = 1.0) =>
|
|
request<SubnetScanStatus>('/discovery/scan', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ subnet, timeout }),
|
|
}),
|
|
|
|
getScanStatus: () => request<SubnetScanStatus>('/discovery/scan/status'),
|
|
|
|
stopSubnetScan: () =>
|
|
request<SubnetScanStatus>('/discovery/scan/stop', { method: 'POST' }),
|
|
};
|
|
|
|
// Virtual Printer types
|
|
// Canonical wire values: `archive`, `review`, `queue`, `proxy`. The legacy
|
|
// `immediate` (→ archive) and `print_queue` (→ queue) names are still
|
|
// accepted by the backend so older API clients keep working, but new code
|
|
// should send the canonical names.
|
|
export type VirtualPrinterMode = 'archive' | 'review' | 'queue' | 'proxy' | 'immediate' | 'print_queue';
|
|
|
|
export interface VirtualPrinterProxyStatus {
|
|
running: boolean;
|
|
target_host: string;
|
|
ftp_port: number;
|
|
mqtt_port: number;
|
|
ftp_connections: number;
|
|
mqtt_connections: number;
|
|
}
|
|
|
|
export interface VirtualPrinterStatus {
|
|
enabled: boolean;
|
|
running: boolean;
|
|
mode: VirtualPrinterMode;
|
|
name: string;
|
|
serial: string;
|
|
model: string;
|
|
model_name: string;
|
|
pending_files: number;
|
|
target_printer_ip?: string; // For proxy mode
|
|
proxy?: VirtualPrinterProxyStatus; // For proxy mode
|
|
}
|
|
|
|
export interface VirtualPrinterSettings {
|
|
enabled: boolean;
|
|
access_code_set: boolean;
|
|
mode: VirtualPrinterMode;
|
|
model: string;
|
|
target_printer_id: number | null; // For proxy mode
|
|
remote_interface_ip: string | null; // For SSDP proxy across networks
|
|
tailscale_disabled: boolean;
|
|
archive_name_source: 'metadata' | 'filename'; // Source for archive's display name
|
|
status: VirtualPrinterStatus;
|
|
}
|
|
|
|
export interface NetworkInterface {
|
|
name: string;
|
|
ip: string;
|
|
netmask: string;
|
|
subnet: string;
|
|
is_alias?: boolean;
|
|
label?: string;
|
|
}
|
|
|
|
export interface VirtualPrinterModels {
|
|
models: Record<string, string>; // SSDP code -> display name
|
|
default: string;
|
|
}
|
|
|
|
export interface PendingUpload {
|
|
id: number;
|
|
filename: string;
|
|
// Resolved name the review card should show — mirrors what archive_print
|
|
// will eventually write to PrintArchive.print_name (#1152 follow-up). Falls
|
|
// back to the stripped filename stem when the 3MF has no embedded title or
|
|
// the operator has chosen the "filename" archive-name source.
|
|
display_name: string;
|
|
file_size: number;
|
|
source_ip: string | null;
|
|
status: string;
|
|
tags: string | null;
|
|
notes: string | null;
|
|
project_id: number | null;
|
|
uploaded_at: string;
|
|
}
|
|
|
|
// Virtual Printer API
|
|
export const virtualPrinterApi = {
|
|
getSettings: () => request<VirtualPrinterSettings>('/settings/virtual-printer'),
|
|
|
|
getModels: () => request<VirtualPrinterModels>('/settings/virtual-printer/models'),
|
|
|
|
updateSettings: (data: {
|
|
enabled?: boolean;
|
|
access_code?: string;
|
|
mode?: 'archive' | 'review' | 'queue' | 'proxy';
|
|
model?: string;
|
|
target_printer_id?: number;
|
|
remote_interface_ip?: string;
|
|
tailscale_disabled?: boolean;
|
|
archive_name_source?: 'metadata' | 'filename';
|
|
}) => {
|
|
const params = new URLSearchParams();
|
|
if (data.enabled !== undefined) params.set('enabled', String(data.enabled));
|
|
if (data.access_code !== undefined) params.set('access_code', data.access_code);
|
|
if (data.mode !== undefined) params.set('mode', data.mode);
|
|
if (data.model !== undefined) params.set('model', data.model);
|
|
if (data.target_printer_id !== undefined) params.set('target_printer_id', String(data.target_printer_id));
|
|
if (data.remote_interface_ip !== undefined) params.set('remote_interface_ip', data.remote_interface_ip);
|
|
if (data.tailscale_disabled !== undefined) params.set('tailscale_disabled', String(data.tailscale_disabled));
|
|
if (data.archive_name_source !== undefined) params.set('archive_name_source', data.archive_name_source);
|
|
|
|
return request<VirtualPrinterSettings>(`/settings/virtual-printer?${params.toString()}`, {
|
|
method: 'PUT',
|
|
});
|
|
},
|
|
};
|
|
|
|
// Multi Virtual Printer API
|
|
export interface VirtualPrinterConfig {
|
|
id: number;
|
|
name: string;
|
|
enabled: boolean;
|
|
mode: VirtualPrinterMode;
|
|
model: string | null;
|
|
model_name: string | null;
|
|
access_code_set: boolean;
|
|
serial: string;
|
|
target_printer_id: number | null;
|
|
auto_dispatch: boolean;
|
|
queue_force_color_match: boolean;
|
|
save_ams_mapping: boolean;
|
|
gcode_injection: boolean;
|
|
tailscale_disabled: boolean;
|
|
bind_ip: string | null;
|
|
remote_interface_ip: string | null;
|
|
position: number;
|
|
status: { running: boolean; pending_files: number; proxy?: VirtualPrinterProxyStatus };
|
|
}
|
|
|
|
export interface VirtualPrinterListResponse {
|
|
printers: VirtualPrinterConfig[];
|
|
models: Record<string, string>;
|
|
}
|
|
|
|
export const multiVirtualPrinterApi = {
|
|
list: () => request<VirtualPrinterListResponse>('/virtual-printers'),
|
|
|
|
get: (id: number) => request<VirtualPrinterConfig>(`/virtual-printers/${id}`),
|
|
|
|
create: (data: {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
mode?: string;
|
|
model?: string;
|
|
access_code?: string;
|
|
target_printer_id?: number;
|
|
auto_dispatch?: boolean;
|
|
queue_force_color_match?: boolean;
|
|
save_ams_mapping?: boolean;
|
|
gcode_injection?: boolean;
|
|
bind_ip?: string;
|
|
remote_interface_ip?: string;
|
|
}) =>
|
|
request<VirtualPrinterConfig>('/virtual-printers', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
update: (id: number, data: {
|
|
name?: string;
|
|
enabled?: boolean;
|
|
mode?: string;
|
|
model?: string;
|
|
access_code?: string;
|
|
target_printer_id?: number;
|
|
auto_dispatch?: boolean;
|
|
queue_force_color_match?: boolean;
|
|
save_ams_mapping?: boolean;
|
|
gcode_injection?: boolean;
|
|
tailscale_disabled?: boolean;
|
|
bind_ip?: string;
|
|
remote_interface_ip?: string;
|
|
}) =>
|
|
request<VirtualPrinterConfig>(`/virtual-printers/${id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
|
|
remove: (id: number) =>
|
|
request<{ detail: string; id: number }>(`/virtual-printers/${id}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
getTailscaleStatus: () =>
|
|
request<TailscaleStatusResponse>('/virtual-printers/tailscale-status'),
|
|
|
|
getCaCertificate: () =>
|
|
request<VPCaCertificate>('/virtual-printers/ca-certificate'),
|
|
|
|
diagnose: (id: number) =>
|
|
request<VPDiagnosticResult>(`/virtual-printers/${id}/diagnostic`),
|
|
};
|
|
|
|
/** The shared CA certificate every virtual printer presents — imported once
|
|
* into the slicer's trust store. Only the public certificate is returned. */
|
|
export interface VPCaCertificate {
|
|
pem: string;
|
|
fingerprint_sha256: string;
|
|
not_valid_after: string;
|
|
}
|
|
|
|
export type VPDiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
|
|
|
|
export interface VPDiagnosticCheck {
|
|
id:
|
|
| 'enabled'
|
|
| 'running'
|
|
| 'bind_interface'
|
|
| 'access_code'
|
|
| 'target_printer'
|
|
| 'port_ftps'
|
|
| 'port_mqtt'
|
|
| 'port_bind'
|
|
| 'certificate';
|
|
status: VPDiagnosticStatus;
|
|
params: Record<string, string | number>;
|
|
}
|
|
|
|
export interface VPDiagnosticResult {
|
|
vp_id: number;
|
|
vp_name: string;
|
|
mode: string;
|
|
overall: 'ok' | 'warnings' | 'problems';
|
|
checks: VPDiagnosticCheck[];
|
|
}
|
|
|
|
export interface TailscaleStatusResponse {
|
|
available: boolean;
|
|
fqdn: string;
|
|
hostname: string;
|
|
tailnet_name: string;
|
|
tailscale_ips: string[];
|
|
error: string | null;
|
|
}
|
|
|
|
// Pending Uploads API
|
|
export const pendingUploadsApi = {
|
|
list: () => request<PendingUpload[]>('/pending-uploads/'),
|
|
|
|
getCount: () => request<{ count: number }>('/pending-uploads/count'),
|
|
|
|
get: (id: number) => request<PendingUpload>(`/pending-uploads/${id}`),
|
|
|
|
archive: (id: number, data?: { tags?: string; notes?: string; project_id?: number }) =>
|
|
request<{ id: number; print_name: string; filename: string }>(`/pending-uploads/${id}/archive`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(data || {}),
|
|
}),
|
|
|
|
discard: (id: number) =>
|
|
request<{ success: boolean }>(`/pending-uploads/${id}`, { method: 'DELETE' }),
|
|
|
|
archiveAll: () =>
|
|
request<{ archived: number; failed: number }>('/pending-uploads/archive-all', { method: 'POST' }),
|
|
|
|
discardAll: () =>
|
|
request<{ discarded: number }>('/pending-uploads/discard-all', { method: 'DELETE' }),
|
|
};
|
|
|
|
// Firmware API Types
|
|
export interface AvailableFirmwareVersion {
|
|
version: string;
|
|
file_available: boolean;
|
|
download_url: string | null;
|
|
release_notes: string | null;
|
|
release_time: string | null;
|
|
}
|
|
|
|
export interface FirmwareUpdateInfo {
|
|
printer_id: number;
|
|
printer_name: string;
|
|
model: string | null;
|
|
current_version: string | null;
|
|
latest_version: string | null;
|
|
update_available: boolean;
|
|
download_url: string | null;
|
|
release_notes: string | null;
|
|
available_versions: AvailableFirmwareVersion[];
|
|
}
|
|
|
|
export interface FirmwareUploadPrepare {
|
|
can_proceed: boolean;
|
|
sd_card_present: boolean;
|
|
sd_card_free_space: number;
|
|
firmware_size: number;
|
|
space_sufficient: boolean;
|
|
update_available: boolean;
|
|
current_version: string | null;
|
|
latest_version: string | null;
|
|
target_version: string | null;
|
|
firmware_filename: string | null;
|
|
errors: string[];
|
|
}
|
|
|
|
export interface FirmwareUploadStatus {
|
|
status: 'idle' | 'preparing' | 'downloading' | 'uploading' | 'complete' | 'error';
|
|
progress: number;
|
|
message: string;
|
|
error: string | null;
|
|
firmware_filename: string | null;
|
|
firmware_version: string | null;
|
|
}
|
|
|
|
// Firmware API
|
|
export const firmwareApi = {
|
|
checkUpdates: () =>
|
|
request<{ updates: FirmwareUpdateInfo[]; updates_available: number }>('/firmware/updates'),
|
|
|
|
checkPrinterUpdate: (printerId: number) =>
|
|
request<FirmwareUpdateInfo>(`/firmware/updates/${printerId}`),
|
|
|
|
prepareUpload: (printerId: number, version?: string) =>
|
|
request<FirmwareUploadPrepare>(
|
|
`/firmware/updates/${printerId}/prepare${version ? `?version=${encodeURIComponent(version)}` : ''}`,
|
|
),
|
|
|
|
startUpload: (printerId: number, version?: string) =>
|
|
request<{ started: boolean; message: string }>(
|
|
`/firmware/updates/${printerId}/upload${version ? `?version=${encodeURIComponent(version)}` : ''}`,
|
|
{ method: 'POST' },
|
|
),
|
|
|
|
getUploadStatus: (printerId: number) =>
|
|
request<FirmwareUploadStatus>(`/firmware/updates/${printerId}/upload/status`),
|
|
};
|
|
|
|
// Support types
|
|
export interface DebugLoggingState {
|
|
enabled: boolean;
|
|
enabled_at: string | null;
|
|
duration_seconds: number | null;
|
|
}
|
|
|
|
export interface LogEntry {
|
|
timestamp: string;
|
|
level: string;
|
|
logger_name: string;
|
|
message: string;
|
|
}
|
|
|
|
export interface LogsResponse {
|
|
entries: LogEntry[];
|
|
total_in_file: number;
|
|
filtered_count: number;
|
|
}
|
|
|
|
// Support API
|
|
export const supportApi = {
|
|
getDebugLoggingState: () =>
|
|
request<DebugLoggingState>('/support/debug-logging'),
|
|
|
|
setDebugLogging: (enabled: boolean) =>
|
|
request<DebugLoggingState>('/support/debug-logging', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ enabled }),
|
|
}),
|
|
|
|
downloadSupportBundle: async () => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/support/bundle`, { headers });
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
// Get filename from Content-Disposition header or use default
|
|
const disposition = response.headers.get('Content-Disposition');
|
|
const filename = parseContentDispositionFilename(disposition) || 'bambuddy-support.zip';
|
|
|
|
// Download the blob
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
|
|
getLogs: (params?: { limit?: number; level?: string; search?: string }) => {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.limit) searchParams.set('limit', params.limit.toString());
|
|
if (params?.level) searchParams.set('level', params.level);
|
|
if (params?.search) searchParams.set('search', params.search);
|
|
const query = searchParams.toString();
|
|
return request<LogsResponse>(`/support/logs${query ? `?${query}` : ''}`);
|
|
},
|
|
|
|
clearLogs: () =>
|
|
request<{ message: string }>('/support/logs', { method: 'DELETE' }),
|
|
};
|
|
|
|
// SpoolBuddy types
|
|
export interface SpoolBuddyDevice {
|
|
id: number;
|
|
device_id: string;
|
|
hostname: string;
|
|
ip_address: string;
|
|
backend_url?: string | null;
|
|
firmware_version: string | null;
|
|
has_nfc: boolean;
|
|
has_scale: boolean;
|
|
tare_offset: number;
|
|
calibration_factor: number;
|
|
nfc_reader_type: string | null;
|
|
nfc_connection: string | null;
|
|
display_brightness: number;
|
|
display_blank_timeout: number;
|
|
has_backlight: boolean;
|
|
last_calibrated_at: string | null;
|
|
last_seen: string | null;
|
|
pending_command: string | null;
|
|
nfc_ok: boolean;
|
|
scale_ok: boolean;
|
|
uptime_s: number;
|
|
update_status: string | null;
|
|
update_message: string | null;
|
|
system_stats: {
|
|
os?: { os?: string; kernel?: string; arch?: string; python?: string };
|
|
cpu_temp_c?: number;
|
|
cpu_count?: number;
|
|
load_avg?: number[];
|
|
memory?: { total_mb?: number; available_mb?: number; used_mb?: number; percent?: number };
|
|
disk?: { total_gb?: number; used_gb?: number; free_gb?: number; percent?: number };
|
|
system_uptime_s?: number;
|
|
} | null;
|
|
online: boolean;
|
|
}
|
|
|
|
export interface DaemonUpdateCheck {
|
|
current_version: string;
|
|
latest_version: string | null;
|
|
update_available: boolean;
|
|
}
|
|
|
|
// SpoolBuddy API
|
|
export const spoolbuddyApi = {
|
|
getDevices: () =>
|
|
request<SpoolBuddyDevice[]>('/spoolbuddy/devices'),
|
|
|
|
deleteDevice: (deviceId: string) =>
|
|
request<{ status: string; device_id: string }>(`/spoolbuddy/devices/${deviceId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
tare: (deviceId: string) =>
|
|
request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
|
|
method: 'POST',
|
|
body: '{}',
|
|
}),
|
|
|
|
getCalibration: (deviceId: string) =>
|
|
request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration`),
|
|
|
|
setCalibrationFactor: (deviceId: string, knownWeightGrams: number, rawAdc: number, tareRawAdc?: number) =>
|
|
request<{ tare_offset: number; calibration_factor: number }>(`/spoolbuddy/devices/${deviceId}/calibration/set-factor`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ known_weight_grams: knownWeightGrams, raw_adc: rawAdc, tare_raw_adc: tareRawAdc }),
|
|
}),
|
|
|
|
updateSpoolWeight: (spoolId: number, weightGrams: number) =>
|
|
request<{ status: string; weight_used: number }>('/spoolbuddy/scale/update-spool-weight', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool_id: spoolId, weight_grams: weightGrams }),
|
|
}),
|
|
|
|
updateDisplay: (deviceId: string, brightness: number, blankTimeout: number) =>
|
|
request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/display`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ brightness, blank_timeout: blankTimeout }),
|
|
}),
|
|
|
|
updateSystemConfig: (deviceId: string, backendUrl: string, apiKey?: string) =>
|
|
request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/system/config`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ backend_url: backendUrl, ...(apiKey ? { api_key: apiKey } : {}) }),
|
|
}),
|
|
|
|
checkDaemonUpdate: (deviceId: string) =>
|
|
request<DaemonUpdateCheck>(`/spoolbuddy/devices/${deviceId}/update-check`),
|
|
|
|
triggerUpdate: (deviceId: string) =>
|
|
request<{ status: string; message: string }>(`/spoolbuddy/devices/${deviceId}/update`, {
|
|
method: 'POST',
|
|
body: '{}',
|
|
}),
|
|
|
|
getSSHPublicKey: () =>
|
|
request<{ public_key: string }>('/spoolbuddy/ssh/public-key'),
|
|
|
|
writeTag: (deviceId: string, spoolId: number) =>
|
|
request<{ status: string; warnings?: string[] }>('/spoolbuddy/nfc/write-tag', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ device_id: deviceId, spool_id: spoolId }),
|
|
}),
|
|
|
|
cancelWrite: (deviceId: string) =>
|
|
request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/cancel-write`, {
|
|
method: 'POST',
|
|
body: '{}',
|
|
}),
|
|
|
|
systemCommand: (deviceId: string, command: 'reboot' | 'shutdown' | 'restart_daemon' | 'restart_browser') =>
|
|
request<{ status: string; command: string }>(`/spoolbuddy/devices/${deviceId}/system/command`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ command }),
|
|
}),
|
|
|
|
queueDiagnostics: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
|
|
request<{ status: string; diagnostic: string; message: string }>(
|
|
`/spoolbuddy/diagnostics/${deviceId}/run?diagnostic=${type}`,
|
|
{ method: 'POST', body: '{}' }
|
|
),
|
|
|
|
getDiagnosticResult: (deviceId: string, type: 'nfc' | 'scale' | 'read_tag') =>
|
|
request<{ diagnostic: string; success: boolean; output: string; exit_code: number }>(
|
|
`/spoolbuddy/diagnostics/${deviceId}/result?diagnostic=${type}`,
|
|
{ method: 'GET' }
|
|
),
|
|
};
|
|
|
|
export interface BugReportRequest {
|
|
description: string;
|
|
email?: string;
|
|
screenshot_base64?: string;
|
|
include_support_info?: boolean;
|
|
debug_logs?: string;
|
|
}
|
|
|
|
export interface BugReportResponse {
|
|
success: boolean;
|
|
message: string;
|
|
issue_url?: string;
|
|
issue_number?: number;
|
|
}
|
|
|
|
export const bugReportApi = {
|
|
submit: (data: BugReportRequest) =>
|
|
request<BugReportResponse>('/bug-report/submit', {
|
|
method: 'POST',
|
|
body: JSON.stringify(data),
|
|
}),
|
|
startLogging: () =>
|
|
request<{ started: boolean; was_debug: boolean }>('/bug-report/start-logging', {
|
|
method: 'POST',
|
|
}),
|
|
stopLogging: (wasDebug: boolean) =>
|
|
request<{ logs: string }>(`/bug-report/stop-logging?was_debug=${wasDebug}`, {
|
|
method: 'POST',
|
|
}),
|
|
};
|
|
|
|
export interface SponsorPromptCheckResponse {
|
|
show: boolean;
|
|
milestone?: string;
|
|
family?: 'prints' | 'cost' | 'archives' | 'anniversary' | 'version-update';
|
|
threshold?: number;
|
|
payload?: Record<string, unknown>;
|
|
}
|
|
|
|
export const sponsorPromptApi = {
|
|
check: () => request<SponsorPromptCheckResponse>('/sponsor-prompt/check'),
|
|
dismiss: (milestone: string) =>
|
|
request<void>('/sponsor-prompt/dismiss', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ milestone }),
|
|
}),
|
|
};
|