mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
The first cut of #1325 swapped a stale hardcoded model table for bundle-based compatibility matching: a cloud / standard process or filament preset was classified by consulting the user's uploaded Slicer Bundles (.bbscfg). That works perfectly when bundles cover every printer in the user's cloud catalogue, and silently no-ops otherwise - every cloud preset resolves to 'unknown', nothing moves into "Other printers", and the dropdowns look identical to the pre-fix state. The reporter saw exactly this on a clean install with no bundles uploaded. Restored BambuStudio's `@BBL <token>` name convention as a third tier below the bundle path, but driven by the canonical backend PRINTER_MODEL_MAP - exposed via a new GET /slicer/printer-models route - rather than a manually-maintained frontend table. The matcher inverts the registry into short-code -> printer-fragment ("X1C" -> "X1 Carbon"), normalises whitespace + case so "A1 mini" and "A1 Mini" compare equal, and falls back to raw-token compare for models not yet in the registry (so a future "Q1" matches without a code change). Adding a new Bambu model still touches exactly the one backend file already listed in the Bambu Model Codes registry. Tests: 2 new in test_slicer_presets.py (route returns the full PRINTER_MODEL_MAP, route returns a copy not the live dict); 11 new in slicerPrinterMatch.test.ts covering registry-driven X1C vs X1 Carbon, A1 vs A1 mini, H2D vs H2D Pro, P2S / H2C / H2S / X2D (which the original hardcoded list was missing), raw-token fallback, registry-not-loaded-yet degradation, and the precedence rules between compatible_printers / bundle / @BBL name. 38 slicer-presets + 36 slicerPrinterMatch + 34 SliceModal tests green; backend ruff clean; frontend build clean.
6825 lines
229 KiB
TypeScript
6825 lines
229 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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// 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-stream 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.
|
|
export interface LongLivedCameraToken {
|
|
id: number;
|
|
user_id: number;
|
|
name: string;
|
|
scope: 'camera_stream';
|
|
lookup_prefix: string;
|
|
created_at: string;
|
|
expires_at: string;
|
|
last_used_at: string | null;
|
|
token: string | null;
|
|
}
|
|
|
|
// Printer types
|
|
export interface Printer {
|
|
id: number;
|
|
name: string;
|
|
serial_number: string;
|
|
ip_address: string;
|
|
access_code: string;
|
|
model: string | null;
|
|
location: string | null; // Group/location name
|
|
nozzle_count: number; // 1 or 2, auto-detected from MQTT
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
module_type: string; // "ams", "n3f", "n3s"
|
|
}
|
|
|
|
export interface NozzleInfo {
|
|
nozzle_type: string; // "stainless_steel" or "hardened_steel"
|
|
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;
|
|
// in[track] = currently loaded slot for that track (-1 = empty)
|
|
in_slots: number[];
|
|
// out[track] = extruder this track terminates at (0 = right, 1 = left)
|
|
out_extruders: number[];
|
|
stat: number;
|
|
info: number;
|
|
}
|
|
|
|
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 (X1/P1S/P2S/H2*)
|
|
nozzles: NozzleInfo[]; // Nozzle hardware info (index 0=left/primary, 1=right)
|
|
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;
|
|
// Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
|
|
tray_now: number;
|
|
// 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
|
|
firmware_version: string | null; // Firmware version from MQTT
|
|
// Developer LAN mode: true = enabled, false = disabled, null = unknown
|
|
developer_mode: 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;
|
|
}
|
|
|
|
export interface PrinterCreate {
|
|
name: string;
|
|
serial_number: string;
|
|
ip_address: string;
|
|
access_code: string;
|
|
model?: string;
|
|
location?: string;
|
|
auto_archive?: 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 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;
|
|
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;
|
|
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;
|
|
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>;
|
|
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;
|
|
}
|
|
|
|
export interface ProjectChildPreview {
|
|
id: number;
|
|
name: string;
|
|
color: string | null;
|
|
status: string;
|
|
progress_percent: number | null;
|
|
}
|
|
|
|
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
|
|
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[];
|
|
created_at: string;
|
|
updated_at: string;
|
|
stats?: ProjectStats;
|
|
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;
|
|
}
|
|
|
|
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
|
|
budget: number | null;
|
|
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
|
|
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;
|
|
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;
|
|
notes?: string;
|
|
tags?: string;
|
|
due_date?: string;
|
|
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;
|
|
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;
|
|
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_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_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_access_cloud?: boolean;
|
|
can_update_energy_cost?: boolean;
|
|
printer_ids?: number[] | null;
|
|
enabled?: boolean;
|
|
expires_at?: string | null;
|
|
}
|
|
|
|
// Settings types
|
|
export interface AppSettings {
|
|
auto_archive: boolean;
|
|
save_thumbnails: boolean;
|
|
capture_finish_photo: 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;
|
|
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
|
|
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
|
|
drying_presets: string; // JSON blob of drying presets per filament type
|
|
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
|
|
// Default printer
|
|
default_printer_id: number | null;
|
|
// 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;
|
|
// 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
|
|
preferred_slicer: 'bambu_studio' | 'orcaslicer';
|
|
// 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;
|
|
// Prometheus metrics
|
|
prometheus_enabled: boolean;
|
|
prometheus_token: string;
|
|
// Bed cooled threshold
|
|
bed_cooled_threshold: number;
|
|
// Inventory low stock threshold
|
|
low_stock_threshold: number;
|
|
// User email notifications toggle
|
|
user_notifications_enabled: boolean;
|
|
// Default print options
|
|
default_bed_levelling: boolean;
|
|
default_flow_cali: boolean;
|
|
default_vibration_cali: boolean;
|
|
default_layer_inspect: boolean;
|
|
default_timelapse: boolean;
|
|
// Staggered batch start defaults
|
|
stagger_group_size: number;
|
|
stagger_interval_minutes: number;
|
|
// Plate-clear confirmation
|
|
require_plate_clear: boolean;
|
|
// Shortest job first scheduling
|
|
queue_shortest_first: boolean;
|
|
// 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_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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface CloudLoginResponse {
|
|
success: boolean;
|
|
needs_verification: boolean;
|
|
message: string;
|
|
verification_type?: 'email' | 'totp' | null;
|
|
tfa_key?: string | null;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 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 = 'cloud' | 'local' | 'standard';
|
|
export interface PresetRef {
|
|
source: PresetSource;
|
|
id: string;
|
|
}
|
|
export interface SliceBundleSpec {
|
|
bundle_id: string;
|
|
printer_name: string;
|
|
process_name: string;
|
|
// Per-slot filament names in plate order. Index 0 = slot 1, etc.
|
|
filament_names: string[];
|
|
}
|
|
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[];
|
|
// Bundle dispatch: when set, the backend skips PresetRef resolution and
|
|
// picks the JSON triplet from a sidecar-stored .bbscfg by name. Mutually
|
|
// exclusive with the preset fields above (validator accepts both, but
|
|
// dispatch ignores the preset side when bundle is set).
|
|
bundle?: SliceBundleSpec;
|
|
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;
|
|
}
|
|
|
|
// GET /api/v1/slicer/bundles — Printer Preset Bundles imported from
|
|
// BambuStudio's "File → Export → Export Preset Bundle" dialog. Each bundle
|
|
// is a .bbscfg zip the user uploads once per printer, after which the
|
|
// SliceModal can pick its inner presets by name (no re-upload per slice).
|
|
// Backend: backend/app/api/routes/slicer_presets.py — bundle endpoints.
|
|
export interface SlicerBundle {
|
|
id: string;
|
|
printer_preset_name: string;
|
|
printer: string[];
|
|
process: string[];
|
|
filament: string[];
|
|
version: string | null;
|
|
}
|
|
|
|
// 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, and otherwise by the user's uploaded Slicer Bundles (#1325).
|
|
compatible_printers?: string[] | null;
|
|
}
|
|
export interface UnifiedPresetsBySlot {
|
|
printer: UnifiedPreset[];
|
|
process: UnifiedPreset[];
|
|
filament: UnifiedPreset[];
|
|
}
|
|
export interface UnifiedPresetsResponse {
|
|
cloud: UnifiedPresetsBySlot;
|
|
local: UnifiedPresetsBySlot;
|
|
standard: UnifiedPresetsBySlot;
|
|
cloud_status: SlicerCloudStatus;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
printer_id: number | null;
|
|
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;
|
|
printer_id?: number | null;
|
|
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;
|
|
printer_id?: number | null;
|
|
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;
|
|
}
|
|
|
|
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
|
|
// Either archive_id OR library_file_id must be set (archive created at print start)
|
|
archive_id: number | null;
|
|
library_file_id: 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;
|
|
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
|
|
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: 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: boolean;
|
|
flow_cali: boolean;
|
|
vibration_cali: boolean;
|
|
layer_inspect: boolean;
|
|
timelapse: boolean;
|
|
use_ams: boolean;
|
|
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
|
|
// 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;
|
|
}
|
|
|
|
export interface PrintBatch {
|
|
id: number;
|
|
name: string;
|
|
archive_id: number | null;
|
|
library_file_id: number | null;
|
|
quantity: number;
|
|
status: string;
|
|
created_at: string;
|
|
created_by_id: number | null;
|
|
created_by_username: string | null;
|
|
pending_count: number;
|
|
printing_count: number;
|
|
completed_count: number;
|
|
failed_count: number;
|
|
cancelled_count: number;
|
|
}
|
|
|
|
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)
|
|
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?: boolean;
|
|
flow_cali?: boolean;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
// Batch: create multiple copies (creates a batch if > 1)
|
|
quantity?: number;
|
|
// Project to associate the resulting archive with
|
|
project_id?: 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?: boolean;
|
|
flow_cali?: boolean;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
}
|
|
|
|
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?: boolean;
|
|
flow_cali?: boolean;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
// Auto-print G-code injection
|
|
gcode_injection?: boolean;
|
|
}
|
|
|
|
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';
|
|
|
|
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;
|
|
// Printer status events
|
|
on_printer_offline: boolean;
|
|
on_printer_error: boolean;
|
|
on_filament_low: boolean;
|
|
on_maintenance_due: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high: boolean;
|
|
on_ams_temperature_high: 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;
|
|
// Bed cooled
|
|
on_bed_cooled: 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;
|
|
// Printer status events
|
|
on_printer_offline?: boolean;
|
|
on_printer_error?: boolean;
|
|
on_filament_low?: boolean;
|
|
on_maintenance_due?: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high?: boolean;
|
|
on_ams_temperature_high?: 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;
|
|
// Bed cooled
|
|
on_bed_cooled?: 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;
|
|
// Printer status events
|
|
on_printer_offline?: boolean;
|
|
on_printer_error?: boolean;
|
|
on_filament_low?: boolean;
|
|
on_maintenance_due?: boolean;
|
|
// AMS environmental alarms (regular AMS)
|
|
on_ams_humidity_high?: boolean;
|
|
on_ams_temperature_high?: 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;
|
|
// Bed cooled
|
|
on_bed_cooled?: 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';
|
|
|
|
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;
|
|
progress: string | null;
|
|
last_backup_at: string | null;
|
|
last_backup_status: string | null;
|
|
next_scheduled_run: string | null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface LocalBackupFile {
|
|
filename: string;
|
|
size: number;
|
|
created_at: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export interface ObicoTestConnection {
|
|
ok: boolean;
|
|
status_code: number | null;
|
|
body: string | null;
|
|
error: string | 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;
|
|
}
|
|
|
|
export interface BackgroundDispatchResponse {
|
|
status: 'dispatched' | string;
|
|
printer_id: number;
|
|
archive_id?: number | null;
|
|
filename: string;
|
|
dispatch_job_id: number;
|
|
dispatch_position: number;
|
|
}
|
|
|
|
// 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 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;
|
|
}
|
|
|
|
export interface SpoolmanBulkCreateResult {
|
|
created: InventorySpool[];
|
|
requested_count: number;
|
|
failed_count: number;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
update_method?: 'docker' | 'git' | 'ha_addon';
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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:create'
|
|
| 'archives:update_own' | 'archives:update_all' | 'archives:delete_own' | 'archives:delete_all'
|
|
| 'archives:reprint_own' | 'archives:reprint_all' | 'archives:purge'
|
|
| 'queue:read' | 'queue:create'
|
|
| 'queue:update_own' | 'queue:update_all' | 'queue:delete_own' | 'queue:delete_all'
|
|
| 'queue:reorder'
|
|
| 'library:read' | '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'
|
|
| 'stats:read' | 'stats:filter_by_user'
|
|
| 'system:read'
|
|
| 'settings:read' | 'settings:update' | 'settings:backup' | 'settings:restore'
|
|
| 'github:backup' | 'github:restore'
|
|
| 'cloud:auth'
|
|
| 'makerworld:view' | 'makerworld:import'
|
|
| 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
|
|
| 'users:read' | 'users:create' | 'users:update' | 'users:delete'
|
|
| 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
|
|
| '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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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/'),
|
|
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) =>
|
|
request<{ success: boolean; error?: string; resolution?: string }>(
|
|
`/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',
|
|
}),
|
|
|
|
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, force: boolean = false) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/bed-jog?distance=${distance}&force=${force}`,
|
|
{ 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' }
|
|
),
|
|
|
|
// 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' }),
|
|
|
|
// 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.
|
|
loadAmsTray: (printerId: number, trayId: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/load?tray_id=${trayId}`,
|
|
{ method: 'POST' }
|
|
),
|
|
|
|
// Unload the currently loaded filament.
|
|
unloadAms: (printerId: number) =>
|
|
request<{ success: boolean; message: string }>(
|
|
`/printers/${printerId}/ams/unload`,
|
|
{ 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;
|
|
}>;
|
|
}>(`/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)}`),
|
|
downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
|
|
const headers: Record<string, string> = {};
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(
|
|
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
|
|
{ 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) || path.split('/').pop() || 'download';
|
|
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);
|
|
},
|
|
downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
|
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify({ paths }),
|
|
});
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({}));
|
|
throw new Error(error.detail || `HTTP ${response.status}`);
|
|
}
|
|
return response.blob();
|
|
},
|
|
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`),
|
|
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' }),
|
|
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;
|
|
}) =>
|
|
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()}`),
|
|
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,
|
|
// Optional bundle context: when supplied, the backend's preview slice
|
|
// (run for unsliced project files) uses slice_with_bundle so gram
|
|
// numbers reflect the same triplet the real print will use. All four
|
|
// fields must be set for the bundle path to engage; partial context
|
|
// falls back to the embedded-settings preview without erroring.
|
|
bundle?: {
|
|
bundle_id: string;
|
|
printer_name: string;
|
|
process_name: string;
|
|
filament_names: string[];
|
|
},
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (plateId !== undefined) qs.set('plate_id', String(plateId));
|
|
if (requestId) qs.set('request_id', requestId);
|
|
if (bundle) {
|
|
qs.set('bundle_id', bundle.bundle_id);
|
|
qs.set('printer_name', bundle.printer_name);
|
|
qs.set('process_name', bundle.process_name);
|
|
qs.set('filament_names', bundle.filament_names.join(';'));
|
|
}
|
|
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}` : ''}`);
|
|
},
|
|
reprintArchive: (
|
|
archiveId: number,
|
|
printerId: number,
|
|
options?: {
|
|
plate_id?: number;
|
|
plate_name?: string;
|
|
ams_mapping?: number[];
|
|
timelapse?: boolean;
|
|
bed_levelling?: boolean;
|
|
flow_cali?: boolean;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
use_ams?: boolean;
|
|
}
|
|
) =>
|
|
request<BackgroundDispatchResponse>(
|
|
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
|
|
{
|
|
method: 'POST',
|
|
headers: options ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: options ? JSON.stringify(options) : undefined,
|
|
}
|
|
),
|
|
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;
|
|
}) => {
|
|
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));
|
|
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
|
|
},
|
|
getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
|
|
clearPrintLog: () =>
|
|
request<{ deleted: number }>('/print-log/', { method: 'DELETE' }),
|
|
|
|
// 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_good?: number;
|
|
ams_humidity_fair?: number;
|
|
ams_temp_good?: number;
|
|
ams_temp_fair?: number;
|
|
bed_cooled_threshold?: number;
|
|
}>('/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' }),
|
|
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'),
|
|
|
|
// 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'),
|
|
|
|
// 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),
|
|
}),
|
|
removeFromQueue: (id: number) =>
|
|
request<{ message: string }>(`/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' });
|
|
},
|
|
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' }),
|
|
|
|
// 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),
|
|
}),
|
|
|
|
// 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',
|
|
}),
|
|
getSpoolmanSettings: () =>
|
|
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: 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; }) =>
|
|
request<{ spoolman_enabled: string; spoolman_url: string; spoolman_sync_mode: string; spoolman_disable_weight_sync: string; spoolman_report_partial_usage: 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 }),
|
|
}),
|
|
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' }),
|
|
resetSpoolUsage: (id: number) =>
|
|
request<InventorySpool>(`/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
|
|
bulkResetSpoolUsage: (spoolIds: number[]) =>
|
|
request<{ reset: number }>(`/inventory/spools/reset-usage-bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool_ids: spoolIds }),
|
|
}),
|
|
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),
|
|
}),
|
|
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: { spool_ids: number[]; template: SpoolLabelTemplate }): 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: { spool_ids: number[]; template: SpoolLabelTemplate }): 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' }),
|
|
getColorCatalog: () =>
|
|
request<ColorCatalogEntry[]>('/inventory/colors'),
|
|
getColorNameMap: () =>
|
|
request<{ colors: 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' }),
|
|
resetSpoolmanInventorySpoolUsage: (id: number) =>
|
|
request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
|
|
bulkResetSpoolmanInventorySpoolUsage: (spoolIds: number[]) =>
|
|
request<{ reset: number }>(`/spoolman/inventory/spools/reset-usage-bulk`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ spool_ids: spoolIds }),
|
|
}),
|
|
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),
|
|
}),
|
|
|
|
// 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 }>('/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' }),
|
|
|
|
// Long-lived camera-stream tokens (#1108)
|
|
createLongLivedCameraToken: (payload: { name: string; expires_in_days: number }) =>
|
|
request<LongLivedCameraToken>('/auth/tokens', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ ...payload, scope: 'camera_stream' }),
|
|
}),
|
|
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' }),
|
|
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}`),
|
|
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}`),
|
|
|
|
// 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) => {
|
|
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));
|
|
return request<LibraryFileListItem[]>(`/library/files?${params}`);
|
|
},
|
|
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 }),
|
|
}),
|
|
printLibraryFile: (
|
|
fileId: number,
|
|
printerId: number,
|
|
options?: {
|
|
plate_id?: number;
|
|
plate_name?: string;
|
|
ams_mapping?: number[];
|
|
bed_levelling?: boolean;
|
|
flow_cali?: boolean;
|
|
vibration_cali?: boolean;
|
|
layer_inspect?: boolean;
|
|
timelapse?: boolean;
|
|
use_ams?: boolean;
|
|
project_id?: number;
|
|
cleanup_library_after_dispatch?: boolean;
|
|
}
|
|
) =>
|
|
request<BackgroundDispatchResponse>(
|
|
`/library/files/${fileId}/print?printer_id=${printerId}`,
|
|
{
|
|
method: 'POST',
|
|
body: options ? JSON.stringify(options) : undefined,
|
|
}
|
|
),
|
|
cancelBackgroundDispatchJob: (jobId: number) =>
|
|
request<{
|
|
status: 'cancelled' | 'cancelling';
|
|
job_id: number;
|
|
source_name: string;
|
|
printer_id: number;
|
|
printer_name: string;
|
|
}>(`/background-dispatch/${jobId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
getLibraryFilePlates: (fileId: number) =>
|
|
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
|
|
getLibraryFileFilamentRequirements: (
|
|
fileId: number,
|
|
plateId?: number,
|
|
requestId?: string,
|
|
// Optional bundle context — see getArchiveFilamentRequirements above
|
|
// for the contract. Same shape so callers can share a builder helper.
|
|
bundle?: {
|
|
bundle_id: string;
|
|
printer_name: string;
|
|
process_name: string;
|
|
filament_names: string[];
|
|
},
|
|
) => {
|
|
const qs = new URLSearchParams();
|
|
if (plateId !== undefined) qs.set('plate_id', String(plateId));
|
|
if (requestId) qs.set('request_id', requestId);
|
|
if (bundle) {
|
|
qs.set('bundle_id', bundle.bundle_id);
|
|
qs.set('printer_name', bundle.printer_name);
|
|
qs.set('process_name', bundle.process_name);
|
|
qs.set('filament_names', bundle.filament_names.join(';'));
|
|
}
|
|
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'),
|
|
|
|
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' }),
|
|
|
|
// Scheduled local backups
|
|
getLocalBackupStatus: () =>
|
|
request<LocalBackupStatus>('/local-backup/status'),
|
|
|
|
triggerLocalBackup: () =>
|
|
request<{ success: boolean; message: string; filename?: string }>('/local-backup/run', { method: 'POST' }),
|
|
|
|
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'),
|
|
|
|
testObicoConnection: (url: string) =>
|
|
request<ObicoTestConnection>('/obico/test-connection', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ url }),
|
|
}),
|
|
|
|
// 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.
|
|
getSlicerPresets: () =>
|
|
request<UnifiedPresetsResponse>('/slicer/presets'),
|
|
|
|
// 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'),
|
|
|
|
// Slicer Bundles (.bbscfg) — Printer Preset Bundles imported from BambuStudio.
|
|
// Settings → Slicer Bundles uploads/lists/deletes; the SliceModal picks
|
|
// presets by name from a chosen bundle (separate follow-up).
|
|
listSlicerBundles: () =>
|
|
request<SlicerBundle[]>('/slicer/bundles'),
|
|
importSlicerBundle: (file: File) => {
|
|
// The /slicer/bundles upload accepts multipart with field name "file"
|
|
// (matches the FastAPI route's UploadFile parameter). Bypass `request`
|
|
// because it always JSON-stringifies the body — multipart needs the
|
|
// browser to set the boundary in the Content-Type header.
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
return fetch(`${API_BASE}/slicer/bundles`, {
|
|
method: 'POST',
|
|
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
|
body: fd,
|
|
}).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<SlicerBundle>;
|
|
});
|
|
},
|
|
deleteSlicerBundle: (bundleId: string) =>
|
|
request<void>(`/slicer/bundles/${encodeURIComponent(bundleId)}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
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 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;
|
|
print_name: string | null;
|
|
print_time_seconds: number | null;
|
|
filament_used_grams: number | null;
|
|
sliced_for_model: string | null;
|
|
}
|
|
|
|
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
|
|
export type VirtualPrinterMode = 'immediate' | 'queue' | 'review' | 'print_queue' | 'proxy'; // 'queue' is legacy, normalized to 'review'
|
|
|
|
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?: 'immediate' | 'review' | 'print_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;
|
|
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;
|
|
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;
|
|
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',
|
|
}),
|
|
};
|