Merge remote-tracking branch 'upstream/dev' into feature/billing

This commit is contained in:
behrinml
2026-08-05 20:19:32 +02:00
215 changed files with 28788 additions and 2152 deletions
+299
View File
@@ -341,6 +341,10 @@ export interface OverlayStatus {
layer_num: number | null;
total_layers: number | null;
stg_cur_name: string | null;
// Nozzle / bed / chamber readings for the overlay's temperature fields
// (#1422). Only the keys a viewer is shown; chamber is absent on models
// without a real sensor.
temperatures: Record<string, number>;
time_format: 'system' | '12h' | '24h';
}
@@ -357,6 +361,10 @@ export interface Printer {
model: string | null;
location: string | null; // Group/location name
nozzle_count: number; // 1 or 2, auto-detected from MQTT
// Model is sold with both Standard and High Flow nozzles, so a K-profile's
// flow type is a real choice. Derived from the model, not the nozzle count —
// only the A-series has a single variant.
supports_nozzle_flow_type: boolean;
is_active: boolean;
auto_archive: boolean;
external_camera_url: string | null;
@@ -1256,6 +1264,8 @@ export interface AppSettings {
mqtt_use_tls: boolean;
// External URL for notifications
external_url: string;
// Directory holding docker-compose.yml, shown in the update instructions (#2664)
docker_compose_dir: string;
// Home Assistant integration
ha_enabled: boolean;
ha_url: string;
@@ -1605,6 +1615,12 @@ export interface SliceRequest {
// instead of the picked profile triplet. The preset refs above are still
// required by the backend validator but go unused on this path.
use_embedded_settings?: boolean;
// Layout passes the slicer runs before slicing (#2548), both off by
// default because they move or rotate the objects the user laid out.
// Unlike the fields above these are CLI actions rather than profile
// values, so they apply on the embedded-settings path too.
auto_orient?: boolean;
auto_arrange?: boolean;
}
// GET /api/v1/slicer/presets — unified listing across cloud / local / standard.
@@ -2156,6 +2172,75 @@ export interface HATestConnectionResult {
error: string | null;
}
// A Home Assistant entity bound to a printer for display on its card (#1148, #448).
// Read-only: unlike a SmartPlug there is nothing here to switch.
export interface PrinterHASensor {
id: number;
printer_id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null; // HA's own class: "door", "temperature", ...
unit: string | null; // numeric sensors only
// What counts as needing attention. Binary sensors use alert_state, numeric
// ones the thresholds; all null means the sensor is display-only.
alert_state: 'on' | 'off' | null;
alert_above: number | null;
alert_below: number | null;
block_print: boolean; // hold the printer's queue while alerting
notify_on_alert: boolean;
show_on_printer_card: boolean;
sort_order: number;
last_state: string | null;
last_changed: string | null;
last_checked: string | null;
created_at: string;
updated_at: string;
}
export interface PrinterHASensorReading {
id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class: string | null;
unit: string | null;
state: string | null; // null when unreadable or not yet polled
value: number | null; // numeric sensors only
alerting: boolean;
block_print: boolean;
reachable: boolean;
last_changed: string | null;
}
export interface PrinterHASensorCreate {
printer_id: number;
name: string;
entity_id: string;
kind: 'binary' | 'numeric';
device_class?: string | null;
unit?: string | null;
alert_state?: 'on' | 'off' | null;
alert_above?: number | null;
alert_below?: number | null;
block_print?: boolean;
notify_on_alert?: boolean;
show_on_printer_card?: boolean;
sort_order?: number;
}
export type PrinterHASensorUpdate = Partial<Omit<PrinterHASensorCreate, 'printer_id'>>;
// An entity offered by the binding picker.
export interface HADisplayEntity {
entity_id: string;
friendly_name: string;
state: string | null;
domain: string; // "binary_sensor" | "sensor"
device_class: string | null;
unit_of_measurement: string | null;
}
export interface SmartPlugEnergy {
power: number | null; // Current watts
voltage: number | null; // Volts
@@ -2204,6 +2289,15 @@ export interface PrintQueueItem {
target_location: string | null; // Target location filter for model-based assignment
required_filament_types: string[] | null; // Required filament types for model-based assignment
waiting_reason: string | null; // Why a model-based job hasn't started yet
// Cross-model alternatives (#671), in priority order. Empty for ordinary
// items. Present until dispatch resolves one, after which library_file_id and
// target_model name the candidate that actually ran.
variants?: Array<{
library_file_id: number;
filename: string;
target_model: string;
position: number;
}>;
// Either archive_id OR library_file_id must be set (archive created at print start)
archive_id: number | null;
library_file_id: number | null;
@@ -2223,6 +2317,10 @@ export interface PrintQueueItem {
// start route when skip_filament_check=true, or at queue creation if
// PrintModal's deficit warning was acknowledged.
skip_filament_check: boolean;
// True when the source archive carries the slicer's own live-resolved
// AMS-slot pick (extra_data.slicer_ams_mapping) — a reprint reuses that
// exact physical spool instead of re-deriving one from type/color.
archive_has_slicer_ams_mapping: boolean;
ams_mapping: number[] | null; // AMS slot mapping for multi-color prints
filament_overrides: Array<{ slot_id: number; type: string; color: string; color_name?: string; tray_info_idx?: string; force_color_match?: boolean }> | null; // Filament overrides for model-based assignment
plate_id: number | null; // Plate ID for multi-plate 3MF files
@@ -2268,6 +2366,32 @@ export interface PrintQueueItem {
cleanup_library_after_dispatch?: boolean;
}
export interface PrintBatchPlateTarget {
plate_id: number | null;
plate_name?: string | null;
quantity_target: number;
sort_order?: number;
}
export interface PrintBatchPlateProgress {
plate_id: number | null;
plate_name: string | null;
quantity_target: number;
dispatched: number;
remaining: number;
pending_count: number;
printing_count: number;
completed_count: number;
failed_count: number;
cancelled_count: number;
skipped_count: number;
/** Measured from finished runs; null until one has produced a cost. */
actual_cost: number | null;
estimated_remaining_cost: number | null;
filament_used_grams: number | null;
print_time_seconds: number;
}
export interface PrintBatch {
id: number;
name: string;
@@ -2276,13 +2400,28 @@ export interface PrintBatch {
quantity: number;
status: string;
created_at: string;
completed_at: string | null;
created_by_id: number | null;
created_by_username: string | null;
project_id: number | null;
due_date: string | null;
notes: string | null;
pending_count: number;
printing_count: number;
completed_count: number;
failed_count: number;
cancelled_count: number;
skipped_count: number;
/** False for batches created before per-plate targets existed (#342):
* they report progress but owe nothing and cannot be dispatched from. */
has_targets: boolean;
target_count: number;
remaining_count: number;
actual_cost: number | null;
estimated_remaining_cost: number | null;
filament_used_grams: number | null;
print_time_seconds: number;
plates: PrintBatchPlateProgress[];
}
export interface PrintQueueItemCreate {
@@ -2325,6 +2464,22 @@ export interface PrintQueueItemCreate {
estimated_cost?: number | null;
// Delete transient uploaded library file after scheduler creates the archive
cleanup_library_after_dispatch?: boolean;
// Cross-model alternatives (#671): several sliced files, one job, whichever
// printer frees up first. Mutually exclusive with printer_id (a named printer
// defeats the point) and with archive_id/library_file_id (these ARE the files).
// Order is priority — index 0 wins when several printers are idle at once.
variants?: QueueVariantCreate[];
}
/** One candidate file for a cross-model queue item (#671). */
export interface QueueVariantCreate {
library_file_id: number;
/** Read from the file's own sliced_for_model unless it declares none. */
target_model?: string | null;
plate_id?: number | null;
ams_mapping?: number[] | null;
nozzle_mapping?: number[] | null;
filament_overrides?: Array<{ slot_id: number; type: string; color: string; color_name?: string; force_color_match?: boolean }> | null;
}
export interface PrintBatchCreate {
@@ -2335,6 +2490,28 @@ export interface PrintBatchCreate {
* (manual "Group as batch"). When omitted/empty, an empty batch is
* returned so the client can pass batch_id on subsequent addToQueue calls. */
item_ids?: number[];
/** Per-plate targets. Omitting them creates a plain grouping batch. */
plates?: PrintBatchPlateTarget[];
project_id?: number | null;
due_date?: string | null;
notes?: string | null;
}
export interface PrintBatchUpdate {
name?: string;
status?: 'active' | 'cancelled';
/** Replaces the full target set — a plate omitted here is removed. */
plates?: PrintBatchPlateTarget[];
project_id?: number | null;
due_date?: string | null;
notes?: string | null;
}
export interface PrintBatchDispatchRequest {
plate_id?: number | null;
only_plate?: boolean;
/** Cap on items created across all plates; omit to queue everything owed. */
limit?: number;
}
export interface PrintQueueItemUpdate {
@@ -2519,6 +2696,7 @@ export interface NotificationProvider {
on_plate_clear_required: boolean;
// Bed cooled
on_bed_cooled: boolean;
on_ha_sensor_alert: boolean;
// First layer complete
on_first_layer_complete: boolean;
// Inventory stock alerts
@@ -2579,6 +2757,7 @@ export interface NotificationProviderCreate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@@ -2632,6 +2811,7 @@ export interface NotificationProviderUpdate {
on_plate_clear_required?: boolean;
// Bed cooled
on_bed_cooled?: boolean;
on_ha_sensor_alert?: boolean;
// First layer complete
on_first_layer_complete?: boolean;
// Inventory stock alerts
@@ -2660,6 +2840,14 @@ export interface NotificationProviderUpdate {
export type ScheduleType = 'hourly' | 'daily' | 'weekly';
export type GitProviderType = 'github' | 'gitea' | 'forgejo' | 'gitlab';
/** How many cloud accounts a backup would collect presets from. Counts only —
* with auth enabled the accounts belong to individual users, so the backup
* administrator sees how many, never whose. */
export interface CloudAccountCounts {
bambu: number;
orca: number;
}
export interface GitHubBackupConfig {
id: number;
repository_url: string;
@@ -3194,6 +3382,10 @@ export interface UpdateCheckResult {
is_windows_installer?: boolean;
update_method?: 'docker' | 'git' | 'ha_addon' | 'windows_installer';
installer_download_url?: string | null;
// Best-effort guess at the compose directory (#2664). Prefill only — the
// value the user saved lives on AppSettings.docker_compose_dir, so clearing
// that field falls back to this guess instead of resurrecting the old value.
compose_dir_detected?: string | null;
}
export interface UpdateStatus {
@@ -3653,6 +3845,11 @@ export interface OIDCProvider {
// #1589: when true, the LoginPage redirects unauthenticated visitors
// straight to this provider on mount. At most one provider may carry this.
is_autologin: boolean;
// #2593: defined by BAMBUDDY_OIDC_* and rewritten from the environment on
// every boot. The API answers 409 to any write, so the settings UI must not
// offer edit/delete controls that cannot succeed. Optional so a response
// from an older backend still type-checks.
is_env_managed?: boolean;
}
export interface OIDCProviderCreate {
@@ -4905,6 +5102,10 @@ export const api = {
dateTo?: string;
limit?: number;
offset?: number;
// Sorting is server-side because paging is: ordering only the rows the
// client holds would sort one page, not the log (#2636).
sortBy?: string;
sortDir?: 'asc' | 'desc';
}) => {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
@@ -4915,6 +5116,8 @@ export const api = {
if (params?.dateTo) searchParams.set('date_to', params.dateTo);
if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset !== undefined) searchParams.set('offset', String(params.offset));
if (params?.sortBy) searchParams.set('sort_by', params.sortBy);
if (params?.sortDir) searchParams.set('sort_dir', params.sortDir);
return request<PrintLogResponse>(`/print-log/?${searchParams}`);
},
getPrintLogThumbnail: (id: number) => withStreamToken(`${API_BASE}/print-log/${id}/thumbnail`),
@@ -5185,6 +5388,22 @@ export const api = {
getHASensorEntities: () =>
request<HASensorEntity[]>('/smart-plugs/ha/sensors'),
// Home Assistant sensors bound to a printer (#1148, #448)
getHASensors: (printerId?: number) =>
request<PrinterHASensor[]>(`/ha-sensors/${printerId ? `?printer_id=${printerId}` : ''}`),
getHASensorReadings: (printerId: number) =>
request<PrinterHASensorReading[]>(`/ha-sensors/by-printer/${printerId}/readings`),
getBindableHAEntities: (search?: string) => {
const params = search ? `?search=${encodeURIComponent(search)}` : '';
return request<HADisplayEntity[]>(`/ha-sensors/entities${params}`);
},
createHASensor: (data: PrinterHASensorCreate) =>
request<PrinterHASensor>('/ha-sensors/', { method: 'POST', body: JSON.stringify(data) }),
updateHASensor: (id: number, data: PrinterHASensorUpdate) =>
request<PrinterHASensor>(`/ha-sensors/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteHASensor: (id: number) =>
request<{ message: string }>(`/ha-sensors/${id}`, { method: 'DELETE' }),
// REST smart plug
testRESTConnection: (url: string, method: string = 'GET', headers?: string | null) =>
request<{ success: boolean; error: string | null }>('/smart-plugs/rest/test-connection', {
@@ -5261,6 +5480,16 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
updateBatch: (id: number, data: PrintBatchUpdate) =>
request<PrintBatch>(`/queue/batches/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
dispatchBatch: (id: number, data: PrintBatchDispatchRequest = {}) =>
request<PrintBatch>(`/queue/batches/${id}/dispatch`, {
method: 'POST',
body: JSON.stringify(data),
}),
ungroupBatch: (id: number) =>
request<{ ungrouped_count: number; message: string }>(
`/queue/batches/${id}/ungroup`,
@@ -6462,6 +6691,50 @@ export const api = {
method: 'POST',
body: JSON.stringify({ file_ids: fileIds, tag_ids: tagIds, action }),
}),
// ============ Variant groups (#671 / #2570) ============
// "These files are the same job sliced for different printers." Consumed from
// both ends: the queue picks a printer and needs the matching file, the File
// Manager's print action has the printer and needs the same match.
createVariantGroup: (
members: { library_file_id: number; target_model?: string }[],
name?: string,
) =>
request<VariantGroup>('/library/variant-groups', {
method: 'POST',
body: JSON.stringify({ members, ...(name ? { name } : {}) }),
}),
getVariantGroup: (groupId: number) =>
request<VariantGroup>(`/library/variant-groups/${groupId}`),
/** Returns null when the file is not grouped, rather than throwing on the 404. */
getVariantGroupForFile: async (fileId: number): Promise<VariantGroup | null> => {
try {
return await request<VariantGroup>(`/library/variant-groups/by-file/${fileId}`);
} catch {
return null;
}
},
updateVariantGroup: (groupId: number, body: { name?: string; member_file_ids?: number[] }) =>
request<VariantGroup>(`/library/variant-groups/${groupId}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
addVariantGroupMember: (
groupId: number,
libraryFileId: number,
targetModel?: string,
) =>
request<VariantGroup>(`/library/variant-groups/${groupId}/members`, {
method: 'POST',
body: JSON.stringify({
library_file_id: libraryFileId,
...(targetModel ? { target_model: targetModel } : {}),
}),
}),
removeVariantGroupMember: (groupId: number, fileId: number) =>
request<void>(`/library/variant-groups/${groupId}/members/${fileId}`, { method: 'DELETE' }),
deleteVariantGroup: (groupId: number) =>
request<void>(`/library/variant-groups/${groupId}`, { method: 'DELETE' }),
getLibraryFile: (id: number) => request<LibraryFile>(`/library/files/${id}`),
uploadLibraryFile: async (
file: File,
@@ -6654,6 +6927,9 @@ export const api = {
getGitHubBackupConfig: () =>
request<GitHubBackupConfig | null>('/github-backup/config'),
getGitHubBackupCloudAccounts: () =>
request<CloudAccountCounts>('/github-backup/cloud-accounts'),
saveGitHubBackupConfig: (config: GitHubBackupConfigCreate) =>
request<GitHubBackupConfig>('/github-backup/config', {
method: 'POST',
@@ -7158,6 +7434,26 @@ export interface LibraryFileListItem {
// legacy code path (or mock) that constructs a LibraryFileListItem without
// it doesn't crash the renderer. Read sites use `file.tags ?? []`.
tags?: LibraryTagSummary[];
// Variant grouping (#671 / #2570). `variant_count` is the size of the whole
// group, which may include files in other folders — never the number of
// matching rows on screen. 0 when the file is not grouped.
variant_group_id?: number | null;
variant_count?: number;
}
// Variant groups (#671 / #2570): the same job sliced for different printers.
export interface VariantGroupMember {
library_file_id: number;
filename: string;
target_model: string;
position: number;
}
export interface VariantGroup {
id: number;
name: string;
/** In priority order — index 0 wins when several printers are free at once. */
members: VariantGroupMember[];
}
// Library tag catalog (#1268)
@@ -7477,6 +7773,7 @@ export interface VirtualPrinterConfig {
target_printer_id: number | null;
auto_dispatch: boolean;
queue_force_color_match: boolean;
save_ams_mapping: boolean;
gcode_injection: boolean;
tailscale_disabled: boolean;
bind_ip: string | null;
@@ -7504,6 +7801,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
save_ams_mapping?: boolean;
gcode_injection?: boolean;
bind_ip?: string;
remote_interface_ip?: string;
@@ -7522,6 +7820,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
save_ams_mapping?: boolean;
gcode_injection?: boolean;
tailscale_disabled?: boolean;
bind_ip?: string;